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
+97
View File
@@ -0,0 +1,97 @@
"""SMTP 메일 발송 유틸 (표준 라이브러리 smtplib 만 사용 — 추가 의존성 없음).
모든 모듈이 공용으로 쓰는 가벼운 발송기. 현재는 프로젝트 관리 모듈의
관리자 알림(업무 배정/완료)에 사용한다.
환경변수:
SMTP_HOST SMTP 서버 호스트 (미설정 시 발송 비활성 — 조용히 skip)
SMTP_PORT 포트 (기본 587)
SMTP_USER 로그인 사용자 (없으면 익명)
SMTP_PASSWORD 로그인 비밀번호
SMTP_FROM 보내는 사람 (미설정 시 SMTP_USER)
SMTP_TLS "true"(STARTTLS, 기본) | "ssl"(SMTPS/465) | "false"(평문)
SMTP_TIMEOUT 연결 타임아웃 초 (기본 10)
설계 원칙:
- 미설정/실패해도 앱이 죽지 않는다. 로그만 남기고 False 반환.
- 호출부는 FastAPI BackgroundTasks 로 비동기 호출 권장(요청 지연 방지).
"""
from __future__ import annotations
import logging
import os
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
logger = logging.getLogger("app.mail")
def _env(name: str, default: str = "") -> str:
return (os.getenv(name, "") or "").strip() or default
def mail_enabled() -> bool:
"""SMTP_HOST 가 있으면 발송 가능으로 본다."""
return bool(_env("SMTP_HOST"))
def send_email(
*,
to: list[str] | str,
subject: str,
body: str,
html: str | None = None,
from_name: str = "DBX ERP",
) -> bool:
"""단순 텍스트(+선택 HTML) 메일 1건 발송.
반환: 성공 True / 비활성·실패 False. 예외를 밖으로 던지지 않는다.
"""
host = _env("SMTP_HOST")
if not host:
logger.info("SMTP 미설정 — 메일 발송 skip (subject=%s)", subject)
return False
recipients = [to] if isinstance(to, str) else list(to)
recipients = [r.strip() for r in recipients if r and r.strip()]
if not recipients:
logger.info("수신자 없음 — 메일 발송 skip (subject=%s)", subject)
return False
port = int(_env("SMTP_PORT", "587"))
user = _env("SMTP_USER")
password = _env("SMTP_PASSWORD")
sender = _env("SMTP_FROM", user or "no-reply@dbxcorp.co.kr")
mode = _env("SMTP_TLS", "true").lower()
timeout = int(_env("SMTP_TIMEOUT", "10"))
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name, sender))
msg["To"] = ", ".join(recipients)
msg.set_content(body)
if html:
msg.add_alternative(html, subtype="html")
try:
if mode == "ssl":
with smtplib.SMTP_SSL(host, port, timeout=timeout) as server:
if user:
server.login(user, password)
server.send_message(msg)
else:
with smtplib.SMTP(host, port, timeout=timeout) as server:
server.ehlo()
if mode != "false":
server.starttls()
server.ehlo()
if user:
server.login(user, password)
server.send_message(msg)
logger.info("메일 발송 완료 → %s (subject=%s)", recipients, subject)
return True
except Exception as exc: # noqa: BLE001 — 메일 실패가 앱을 막으면 안 됨
logger.warning("메일 발송 실패 (subject=%s): %s", subject, exc)
return False
+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"])
+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 %}
+124
View File
@@ -0,0 +1,124 @@
/* 프로젝트 관리(아사나식) 모듈 스타일.
* 기존 erp.css 토큰 위에 아사나 느낌(밝은 캔버스, 색 점, 칸반 컬럼)을 얹는다. */
.pj-wrap { padding: 4px 2px 40px; }
/* ── 버튼 ── */
.pj-btn {
border: 1px solid #d8dce2; background: #fff; color: #1e1f21;
padding: 7px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; transition: background .12s, border-color .12s;
}
.pj-btn:hover { background: #f6f7f8; }
.pj-btn-primary { background: #1e1f21; color: #fff; border-color: #1e1f21; }
.pj-btn-primary:hover { background: #000; }
.pj-btn-danger { background: #fff; color: #c22b10; border-color: #e6c4bd; }
.pj-btn-danger:hover { background: #fbecea; }
.pj-icon-btn {
border: none; background: transparent; color: #6b7280; cursor: pointer;
font-size: 16px; line-height: 1; width: 24px; height: 24px; border-radius: 6px;
}
.pj-icon-btn:hover { background: #eceef0; color: #1e1f21; }
.pj-section-head { display: flex; align-items: center; justify-content: space-between; margin: 8px 0 14px; }
.pj-section-head h2 { font-size: 16px; font-weight: 700; margin: 0; }
.pj-empty { padding: 28px; text-align: center; color: #6b7280; background: #f7f8f9; border-radius: 12px; }
.pj-empty-sm { padding: 10px 2px; color: #9aa1a9; font-size: 13px; }
.pj-chip { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #eef0f2; color: #525860; font-size: 11px; font-weight: 600; }
.pj-chip-sm { padding: 1px 6px; font-size: 10px; }
/* ── 홈 ── */
.pj-home { display: grid; grid-template-columns: 1fr 320px; gap: 28px; align-items: start; }
@media (max-width: 980px) { .pj-home { grid-template-columns: 1fr; } }
.pj-project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.pj-project-card {
display: flex; gap: 12px; padding: 16px; border: 1px solid #e7e9ec; border-radius: 14px;
background: #fff; text-decoration: none; color: inherit; transition: box-shadow .14s, transform .1s;
}
.pj-project-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); transform: translateY(-1px); }
.pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; }
.pj-project-name { font-weight: 700; font-size: 15px; }
.pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; }
.pj-project-meta { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.pj-home-side { border-left: 1px solid #eef0f2; padding-left: 24px; }
@media (max-width: 980px) { .pj-home-side { border-left: none; padding-left: 0; } }
.pj-mytask-list { list-style: none; margin: 0; padding: 0; }
.pj-mytask a { display: flex; align-items: center; gap: 8px; padding: 9px 6px; border-radius: 8px; text-decoration: none; color: inherit; }
.pj-mytask a:hover { background: #f6f7f8; }
.pj-mytask.is-done .pj-mytask-title { text-decoration: line-through; color: #9aa1a9; }
.pj-mytask-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.pj-mytask-title { font-size: 13.5px; font-weight: 600; }
.pj-mytask-meta { margin-left: auto; font-size: 11px; color: #9aa1a9; }
/* ── 프로젝트 페이지 레이아웃 ── */
.pj-layout { display: grid; grid-template-columns: 230px 1fr; gap: 22px; align-items: start; }
@media (max-width: 900px) { .pj-layout { grid-template-columns: 1fr; } }
.pj-side-block { margin-bottom: 22px; }
.pj-side-head { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
.pj-sub-list, .pj-member-list { list-style: none; margin: 0; padding: 0; }
.pj-sub-list li, .pj-member-list li { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 7px; }
.pj-sub-list li:hover, .pj-member-list li:hover { background: #f6f7f8; }
.pj-sub-list a { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; font-size: 13.5px; flex: 1; }
.pj-sub-dot { width: 8px; height: 8px; border-radius: 50%; }
.pj-member-name { font-size: 13px; flex: 1; }
.pj-avatar { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; background: #d6e0f5; color: #2b4a85; font-size: 11px; font-weight: 700; flex: 0 0 auto; }
.pj-avatar-sm { width: 20px; height: 20px; font-size: 10px; }
/* ── 툴바 + 탭 ── */
.pj-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.pj-view-tabs { display: inline-flex; background: #eef0f2; border-radius: 10px; padding: 3px; gap: 2px; }
.pj-tab { border: none; background: transparent; padding: 7px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #525860; cursor: pointer; }
.pj-tab.is-active { background: #fff; color: #1e1f21; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
/* ── 보드(칸반) ── */
.pj-board { display: flex; gap: 14px; overflow-x: auto; padding-bottom: 8px; align-items: flex-start; }
.pj-col { flex: 0 0 272px; background: #f7f8f9; border-radius: 12px; padding: 10px; }
.pj-col-head { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 13px; padding: 4px 6px 10px; }
.pj-col-count { margin-left: auto; color: #9aa1a9; font-weight: 600; }
.pj-col-body { display: flex; flex-direction: column; gap: 8px; min-height: 40px; border-radius: 8px; }
.pj-col-body.is-over { background: #e6ecfa; outline: 2px dashed #aebfe6; }
.pj-card { background: #fff; border: 1px solid #e7e9ec; border-radius: 10px; padding: 10px 12px; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.pj-card:hover { border-color: #c9cdd3; }
.pj-card.is-dragging { opacity: .5; }
.pj-card.is-done .pj-card-title { text-decoration: line-through; color: #9aa1a9; }
.pj-card-title { font-size: 13.5px; font-weight: 600; }
.pj-card-meta { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
.pj-card-due { margin-left: auto; font-size: 11px; color: #9aa1a9; }
.pj-pri { padding: 1px 7px; border-radius: 999px; font-size: 10px; font-weight: 700; }
.pj-pri-high { background: #fbe3df; color: #c22b10; }
.pj-pri-low { background: #e6eef0; color: #4a6066; }
/* ── 리스트 ── */
.pj-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.pj-table th { text-align: left; padding: 9px 10px; border-bottom: 2px solid #eef0f2; color: #6b7280; font-size: 12px; }
.pj-table td { padding: 10px; border-bottom: 1px solid #f1f2f4; }
.pj-table tr.is-done td { color: #9aa1a9; text-decoration: line-through; }
/* ── 달력/타임라인 컨테이너 ── */
#pj-calendar { background: #fff; border-radius: 12px; padding: 10px; }
#pj-timeline { background: #fff; border-radius: 12px; padding: 6px; }
.fc { font-size: 13px; }
/* ── 모달 ── */
.pj-modal { position: fixed; inset: 0; background: rgba(20,22,26,.46); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.pj-modal[hidden] { display: none; }
.pj-modal-card { background: #fff; border-radius: 16px; padding: 22px; width: min(480px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.25); }
.pj-modal-card h3 { margin: 0 0 16px; font-size: 17px; }
.pj-modal-card label { display: block; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; }
.pj-modal-card input[type=text], .pj-modal-card input[type=date], .pj-modal-card textarea, .pj-modal-card select {
display: block; width: 100%; margin-top: 5px; padding: 8px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13.5px; font-family: inherit; box-sizing: border-box;
}
.pj-form-row { display: flex; gap: 12px; }
.pj-form-row label { flex: 1; }
.pj-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.pj-modal-actions .pj-btn:last-child { }
.pj-color-palette, #pj-f-colors { display: flex; gap: 8px; margin-top: 6px; }
.pj-color-swatch { width: 24px; height: 24px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; }
.pj-color-swatch.is-active { border-color: #1e1f21; box-shadow: 0 0 0 2px #fff inset; }
+399
View File
@@ -0,0 +1,399 @@
/* 프로젝트 관리(아사나식) 모듈 — 프론트엔드.
* 두 페이지에서 공용:
* - index.html (홈): 새 프로젝트 모달
* - project.html: 달력/타임라인/보드/리스트 뷰 + 업무 CRUD + 서브/멤버 관리
* 의존: FullCalendar(전역), vis(전역). 둘 다 project.html 에서만 로드.
*/
(function () {
"use strict";
// ── 공용 fetch ─────────────────────────────────────────────
async function api(method, url, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function el(id) { return document.getElementById(id); }
function openModal(m) { if (m) m.hidden = false; }
function closeModal(m) { if (m) m.hidden = true; }
function wireModalClose(modal) {
if (!modal) return;
modal.addEventListener("click", function (e) {
if (e.target === modal || e.target.hasAttribute("data-close")) closeModal(modal);
});
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette(container, onPick) {
const colors = window.PJ_COLORS || ["#4573d2"];
let selected = colors[0];
container.innerHTML = "";
colors.forEach(function (c) {
const b = document.createElement("button");
b.type = "button";
b.className = "pj-color-swatch";
b.style.background = c;
if (c === selected) b.classList.add("is-active");
b.addEventListener("click", function () {
selected = c;
container.querySelectorAll(".pj-color-swatch").forEach(function (x) {
x.classList.remove("is-active");
});
b.classList.add("is-active");
if (onPick) onPick(c);
});
container.appendChild(b);
});
return function () { return selected; };
}
// ════════════════════════════════════════════════════════════
// 홈 페이지 — 새 프로젝트
// ════════════════════════════════════════════════════════════
function initHome() {
const newBtn = el("pj-new-project");
const modal = el("pj-modal-project");
if (!newBtn || !modal) return;
wireModalClose(modal);
const getColor = renderColorPalette(el("pj-f-colors"));
newBtn.addEventListener("click", function () { openModal(modal); });
el("pj-save-project").addEventListener("click", async function () {
const name = el("pj-f-name").value.trim();
if (!name) { alert("이름을 입력하세요."); return; }
try {
await api("POST", "/project/api/projects", {
name: name,
description: el("pj-f-desc").value.trim(),
start_date: el("pj-f-start").value || null,
due_date: el("pj-f-due").value || null,
color: getColor(),
});
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
}
// ════════════════════════════════════════════════════════════
// 프로젝트 페이지
// ════════════════════════════════════════════════════════════
function initProject() {
const root = document.querySelector(".pj-project-view");
if (!root) return;
const projectId = parseInt(root.dataset.projectId, 10);
const canManage = root.dataset.canManage === "true";
const isAdmin = root.dataset.isAdmin === "true";
const dataEl = el("pj-data");
const data = JSON.parse(dataEl.textContent);
let stages = data.stages || [];
let tasks = data.tasks || [];
const members = data.members || [];
const priorityLabels = data.priorityLabels || {};
let calendar = null;
let timeline = null;
let currentView = "calendar";
// ── 뷰 토글 ──
const tabs = el("pj-view-tabs");
tabs.addEventListener("click", function (e) {
const btn = e.target.closest(".pj-tab");
if (!btn) return;
const view = btn.dataset.view;
tabs.querySelectorAll(".pj-tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn);
});
document.querySelectorAll(".pj-view").forEach(function (v) {
v.hidden = v.dataset.view !== view;
});
currentView = view;
renderView(view);
});
function renderView(view) {
if (view === "calendar") renderCalendar();
else if (view === "timeline") renderTimeline();
else if (view === "board") renderBoard();
else if (view === "list") renderList();
}
// ── 달력 (FullCalendar) ──
function taskEvents() {
return tasks.filter(function (t) { return t.due_date || t.start_date; })
.map(function (t) {
const start = t.start_date || t.due_date;
// FullCalendar end 는 배타적 → 마감일 포함 위해 +1일은 생략(단일일 표시)
return {
id: String(t.id),
title: t.title,
start: start,
end: t.due_date && t.due_date !== start ? t.due_date : undefined,
backgroundColor: t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2"),
borderColor: "transparent",
extendedProps: { task: t },
};
});
}
function renderCalendar() {
const node = el("pj-calendar");
if (calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); calendar.render(); return; }
calendar = new FullCalendar.Calendar(node, {
initialView: "dayGridMonth",
locale: "ko",
height: "auto",
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
events: taskEvents(),
eventClick: function (info) {
info.jsEvent.preventDefault();
if (canManage) openTaskModal(info.event.extendedProps.task);
},
});
calendar.render();
}
// ── 타임라인 (vis-timeline) ──
function renderTimeline() {
const node = el("pj-timeline");
const items = tasks.filter(function (t) { return t.start_date || t.due_date; })
.map(function (t) {
const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date;
return {
id: t.id,
content: t.title,
start: s,
end: e !== s ? e : undefined,
type: e !== s ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + ";color:#fff;border:none;",
};
});
if (timeline) { timeline.destroy(); timeline = null; }
if (!items.length) { node.innerHTML = '<div class="pj-empty">기간이 설정된 업무가 없습니다.</div>'; return; }
timeline = new vis.Timeline(node, new vis.DataSet(items), {
locale: "ko", stack: true, height: "520px", zoomKey: "ctrlKey",
margin: { item: 8 },
});
timeline.on("select", function (props) {
if (!canManage || !props.items.length) return;
const t = tasks.find(function (x) { return x.id === props.items[0]; });
if (t) openTaskModal(t);
});
}
// ── 보드 (칸반) ──
function renderBoard() {
const board = el("pj-board");
board.innerHTML = "";
stages.forEach(function (stage) {
const col = document.createElement("div");
col.className = "pj-col";
col.dataset.stageId = stage.id;
col.innerHTML =
'<div class="pj-col-head">' + escapeHtml(stage.name) +
(stage.is_done_stage ? ' <span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span></div>' +
'<div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body");
const colTasks = tasks.filter(function (t) { return t.stage_id === stage.id; });
col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage) enableDrop(body, stage.id);
board.appendChild(col);
});
}
function taskCard(t) {
const card = document.createElement("div");
card.className = "pj-card" + (t.completed_at ? " is-done" : "");
card.draggable = canManage;
card.dataset.taskId = t.id;
card.innerHTML =
'<div class="pj-card-title">' + escapeHtml(t.title) + "</div>" +
'<div class="pj-card-meta">' +
(t.priority && t.priority !== "normal"
? '<span class="pj-pri pj-pri-' + t.priority + '">' + (priorityLabels[t.priority] || t.priority) + "</span>"
: "") +
(t.assignee_email ? '<span class="pj-avatar pj-avatar-sm">' + escapeHtml((t.assignee_name || t.assignee_email)[0].toUpperCase()) + "</span>" : "") +
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
"</div>";
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
if (canManage) {
card.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", String(t.id));
card.classList.add("is-dragging");
});
card.addEventListener("dragend", function () { card.classList.remove("is-dragging"); });
}
return card;
}
function enableDrop(body, stageId) {
body.addEventListener("dragover", function (e) { e.preventDefault(); body.classList.add("is-over"); });
body.addEventListener("dragleave", function () { body.classList.remove("is-over"); });
body.addEventListener("drop", async function (e) {
e.preventDefault();
body.classList.remove("is-over");
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10);
const t = tasks.find(function (x) { return x.id === taskId; });
if (!t || t.stage_id === stageId) return;
try {
const res = await api("PUT", "/project/api/tasks/" + taskId, { stage_id: stageId });
mergeTask(res.task);
renderBoard();
} catch (err) { alert("이동 실패: " + err.message); }
});
}
// ── 리스트 ──
function renderList() {
const tbody = el("pj-list").querySelector("tbody");
tbody.innerHTML = "";
tasks.forEach(function (t) {
const tr = document.createElement("tr");
if (t.completed_at) tr.className = "is-done";
tr.innerHTML =
"<td>" + escapeHtml(t.title) + "</td>" +
"<td>" + escapeHtml(t.assignee_name || t.assignee_email || "-") + "</td>" +
"<td>" + escapeHtml(t.stage_name || "-") + "</td>" +
"<td>" + (priorityLabels[t.priority] || t.priority || "-") + "</td>" +
"<td>" + (t.start_date || "-") + "</td>" +
"<td>" + (t.due_date || "-") + "</td>";
if (canManage) { tr.style.cursor = "pointer"; tr.addEventListener("click", function () { openTaskModal(t); }); }
tbody.appendChild(tr);
});
}
function mergeTask(task) {
const i = tasks.findIndex(function (x) { return x.id === task.id; });
if (i >= 0) tasks[i] = Object.assign({}, tasks[i], task);
else tasks.push(task);
}
function refreshActiveView() {
if (currentView === "calendar" && calendar) { calendar.removeAllEvents(); calendar.addEventSource(taskEvents()); }
else renderView(currentView);
}
// ── 업무 모달 ──
const taskModal = el("pj-modal-task");
wireModalClose(taskModal);
function openTaskModal(task) {
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
el("pj-t-id").value = task ? task.id : "";
el("pj-t-title").value = task ? task.title : "";
el("pj-t-desc").value = task ? (task.description || "") : "";
el("pj-t-assignee").value = task ? (task.assignee_email || "") : "";
el("pj-t-stage").value = task && task.stage_id ? task.stage_id : (stages[0] ? stages[0].id : "");
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
el("pj-t-start").value = task ? (task.start_date || "") : "";
el("pj-t-due").value = task ? (task.due_date || "") : "";
el("pj-delete-task").hidden = !task;
openModal(taskModal);
}
const newTaskBtn = el("pj-new-task");
if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); });
el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const payload = {
title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(),
assignee_email: assigneeSel.value,
assignee_name: assigneeName,
stage_id: parseInt(el("pj-t-stage").value, 10) || null,
priority: el("pj-t-priority").value,
start_date: el("pj-t-start").value || null,
due_date: el("pj-t-due").value || null,
};
if (!payload.title) { alert("제목을 입력하세요."); return; }
try {
let res;
if (id) res = await api("PUT", "/project/api/tasks/" + id, payload);
else res = await api("POST", "/project/api/projects/" + projectId + "/tasks", payload);
mergeTask(res.task);
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("저장 실패: " + e.message); }
});
el("pj-delete-task").addEventListener("click", async function () {
const id = el("pj-t-id").value;
if (!id || !confirm("이 업무를 삭제할까요?")) return;
try {
await api("DELETE", "/project/api/tasks/" + id);
tasks = tasks.filter(function (x) { return String(x.id) !== String(id); });
closeModal(taskModal);
refreshActiveView();
} catch (e) { alert("삭제 실패: " + e.message); }
});
// ── 서브프로젝트 추가/삭제 ──
const newSubBtn = el("pj-new-sub");
if (newSubBtn) newSubBtn.addEventListener("click", async function () {
const name = prompt("서브프로젝트 이름");
if (!name || !name.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/subprojects", { name: name.trim() });
location.reload();
} catch (e) { alert("생성 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-sub").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 서브프로젝트를 삭제할까요? (하위 업무도 모두 삭제)")) return;
try { await api("DELETE", "/project/api/projects/" + b.dataset.id); location.reload(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
// ── 멤버 배정/제외 (관리자) ──
const addMemberBtn = el("pj-add-member");
if (addMemberBtn) addMemberBtn.addEventListener("click", async function () {
const email = prompt("배정할 직원 이메일 (@dbxcorp.co.kr)");
if (!email || !email.trim()) return;
try {
await api("POST", "/project/api/projects/" + projectId + "/members", {
user_email: email.trim(), role: "member",
});
location.reload();
} catch (e) { alert("배정 실패: " + e.message); }
});
document.querySelectorAll(".pj-del-member").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 멤버를 프로젝트에서 제외할까요?")) return;
try {
await api("DELETE", "/project/api/projects/" + projectId + "/members/" + encodeURIComponent(b.dataset.email));
location.reload();
} catch (e) { alert("제외 실패: " + e.message); }
});
});
// 최초 렌더
renderCalendar();
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
document.addEventListener("DOMContentLoaded", function () {
initHome();
initProject();
});
})();