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:
+97
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)})
|
||||
@@ -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 %}
|
||||
@@ -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; }
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initHome();
|
||||
initProject();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user