From c192d61c718fc3076b24c3e86461769dc7fa6c3f Mon Sep 17 00:00:00 2001 From: king Date: Thu, 25 Jun 2026 11:22:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(project):=20=EC=95=84=EC=82=AC=EB=82=98?= =?UTF-8?q?=EC=8B=9D=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 18 + CLAUDE.md | 1 + app/mail.py | 97 +++ app/main.py | 22 +- app/modules/project/__init__.py | 34 ++ app/modules/project/db.py | 519 ++++++++++++++++ app/modules/project/router.py | 562 ++++++++++++++++++ app/modules/project/store.py | 120 ++++ .../project/templates/project/index.html | 98 +++ .../project/templates/project/project.html | 166 ++++++ app/static/project.css | 124 ++++ app/static/project.js | 399 +++++++++++++ docs/PROJECT_MODULE.md | 60 ++ scripts/sql/project_db_init.sql | 185 ++++++ 14 files changed, 2404 insertions(+), 1 deletion(-) create mode 100644 app/mail.py create mode 100644 app/modules/project/__init__.py create mode 100644 app/modules/project/db.py create mode 100644 app/modules/project/router.py create mode 100644 app/modules/project/store.py create mode 100644 app/modules/project/templates/project/index.html create mode 100644 app/modules/project/templates/project/project.html create mode 100644 app/static/project.css create mode 100644 app/static/project.js create mode 100644 docs/PROJECT_MODULE.md create mode 100644 scripts/sql/project_db_init.sql diff --git a/.env.example b/.env.example index b7fe103..77117fc 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,24 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/ # 개인정보(고객 이름/주소/전화)는 저장하지 않는다. # DISPATCH_DB_URL=postgresql://dispatch_app:replace-me@postgres-db:5432/dispatch_db +# ─── 프로젝트 관리 모듈 (project_db) — 아사나식 ─── +# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음). +# DB/역할/스키마 생성: scripts/sql/project_db_init.sql 참고. +# 진입 권한: 로그인한 회사 직원 전원(별도 권한키 없음). 프로젝트 생성/배정은 관리자만. +# PROJECT_DB_URL=postgresql://project_app:replace-me@postgres-db:5432/project_db + +# ─── 메일 알림 (SMTP) — 프로젝트 업무 배정/완료 시 관리자 알림 ─── +# SMTP_HOST 가 있어야 발송. 미설정 시 조용히 skip(앱은 정상 동작). +# SMTP_TLS: true(STARTTLS,587 기본) | ssl(SMTPS,465) | false(평문) +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USER=alarm@dbxcorp.co.kr +# SMTP_PASSWORD=app-password-here +# SMTP_FROM=alarm@dbxcorp.co.kr +# SMTP_TLS=true +# 알림 수신자(쉼표구분). 비워두면 ERP 관리자(admin) 전원에게 발송. +# PROJECT_NOTIFY_EMAIL=king@dbxcorp.co.kr + # ─── 상품 검색 (itemcode_db 읽기 전용) ─── # cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만). # 미설정 시 검색 비활성 → 수동 등록만 가능. diff --git a/CLAUDE.md b/CLAUDE.md index 415e628..3a14565 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia` - 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md` +- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. **진입은 로그인한 회사 직원 전원**(권한키 없음 — MODULE_KEYS 미포함, 메뉴 `allowed` 강제 True). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md` 상세는 `docs/PROJECT_OVERVIEW.md`. diff --git a/app/mail.py b/app/mail.py new file mode 100644 index 0000000..4ffd446 --- /dev/null +++ b/app/mail.py @@ -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 diff --git a/app/main.py b/app/main.py index 39ac739..0f0d441 100644 --- a/app/main.py +++ b/app/main.py @@ -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": '', "malaysia": '', "dispatch": '', + "project": '', "modules": '', } body = paths.get(name, paths["modules"]) diff --git a/app/modules/project/__init__.py b/app/modules/project/__init__.py new file mode 100644 index 0000000..eee1842 --- /dev/null +++ b/app/modules/project/__init__.py @@ -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) diff --git a/app/modules/project/db.py b/app/modules/project/db.py new file mode 100644 index 0000000..46de369 --- /dev/null +++ b/app/modules/project/db.py @@ -0,0 +1,519 @@ +"""project_db PostgreSQL 저장소. + +- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴. +- 연결 정보: 환경변수 `PROJECT_DB_URL` + (예: postgresql://project_app:@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 diff --git a/app/modules/project/router.py b/app/modules/project/router.py new file mode 100644 index 0000000..401cf74 --- /dev/null +++ b/app/modules/project/router.py @@ -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)}) diff --git a/app/modules/project/store.py b/app/modules/project/store.py new file mode 100644 index 0000000..6495f39 --- /dev/null +++ b/app/modules/project/store.py @@ -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}" diff --git a/app/modules/project/templates/project/index.html b/app/modules/project/templates/project/index.html new file mode 100644 index 0000000..4d2ba20 --- /dev/null +++ b/app/modules/project/templates/project/index.html @@ -0,0 +1,98 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+ +
+ +
+
+

프로젝트

+ {% if is_admin %} + + {% endif %} +
+ + {% if not tree %} +
+ 아직 프로젝트가 없습니다. + {% if is_admin %}상단의 새 프로젝트 버튼으로 만들어 보세요. + {% else %}관리자가 프로젝트에 배정하면 여기에 표시됩니다.{% endif %} +
+ {% endif %} + + +
+ + + +
+
+ + +{% if is_admin %} + +{% endif %} + + + +{% endblock %} diff --git a/app/modules/project/templates/project/project.html b/app/modules/project/templates/project/project.html new file mode 100644 index 0000000..53699e7 --- /dev/null +++ b/app/modules/project/templates/project/project.html @@ -0,0 +1,166 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + + + + +{% endblock %} + +{% block content %} +
+ +
+ + + + +
+
+
+ + + + +
+ {% if can_manage %} + + {% endif %} +
+ + +
+
+
+ + + + + + +
+
+
+ + + + + + + + + + +{% endblock %} diff --git a/app/static/project.css b/app/static/project.css new file mode 100644 index 0000000..0e5b7aa --- /dev/null +++ b/app/static/project.css @@ -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; } diff --git a/app/static/project.js b/app/static/project.js new file mode 100644 index 0000000..0e7360d --- /dev/null +++ b/app/static/project.js @@ -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 = '
기간이 설정된 업무가 없습니다.
'; 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 = + '
' + escapeHtml(stage.name) + + (stage.is_done_stage ? ' 완료' : "") + + '
' + + '
'; + 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 = + '
' + escapeHtml(t.title) + "
" + + '
' + + (t.priority && t.priority !== "normal" + ? '' + (priorityLabels[t.priority] || t.priority) + "" + : "") + + (t.assignee_email ? '' + escapeHtml((t.assignee_name || t.assignee_email)[0].toUpperCase()) + "" : "") + + (t.due_date ? '' + t.due_date + "" : "") + + "
"; + 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 = + "" + escapeHtml(t.title) + "" + + "" + escapeHtml(t.assignee_name || t.assignee_email || "-") + "" + + "" + escapeHtml(t.stage_name || "-") + "" + + "" + (priorityLabels[t.priority] || t.priority || "-") + "" + + "" + (t.start_date || "-") + "" + + "" + (t.due_date || "-") + ""; + 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, "'"); + } + + document.addEventListener("DOMContentLoaded", function () { + initHome(); + initProject(); + }); +})(); diff --git a/docs/PROJECT_MODULE.md b/docs/PROJECT_MODULE.md new file mode 100644 index 0000000..4aec8b8 --- /dev/null +++ b/docs/PROJECT_MODULE.md @@ -0,0 +1,60 @@ +# 프로젝트 관리 모듈 (아사나식) — `app/modules/project/` + +> 회사(dbxcorp.co.kr) 직원이 프로젝트·서브프로젝트·업무를 달력/타임라인/보드로 +> 관리하는 아사나(Asana) 스타일 협업 모듈. DB는 `project_db` 전용. + +## 1. 개요 + +| 항목 | 값 | +| --- | --- | +| URL prefix | `/project` | +| DB | `project_db` (PostgreSQL, 역할 `project_app` CRUD only) | +| 연결 env | `PROJECT_DB_URL` (미설정 시 "설정 필요" 안내, 앱은 죽지 않음) | +| 진입 권한 | 로그인한 회사 직원 전원 (별도 권한키 없음) | +| 관리 권한 | 프로젝트 생성/삭제·사용자 배정 = ERP 관리자(`is_admin`) / 서브프로젝트·업무·단계 = 배정 멤버 또는 owner | +| 메일 알림 | `app/mail.py` (stdlib smtplib). 업무 배정/완료 시 관리자에게 발송 | + +## 2. 데이터 모델 (`scripts/sql/project_db_init.sql`) + +- `projects` — `parent_id`(self-FK, NULL=최상위 / NOT NULL=서브프로젝트, `ON DELETE CASCADE`), name, description, color, owner_email, start/due_date, status(active|archived). +- `project_members` — 프로젝트↔사용자 배정. role(manager|member), `UNIQUE(project_id, user_email)`. +- `project_stages` — 진행단계(칸반 컬럼). 프로젝트 생성 시 기본 4단계 seed(할 일/진행 중/검토/완료). `is_done_stage`=TRUE 단계로 옮기면 업무 완료 처리. +- `tasks` — 업무. stage_id, title, description, assignee_email/name, priority(low|normal|high), start/due_date, completed_at. +- `task_comments` — 댓글(스켈레톤 테이블, UI는 추후). +- `project_activity` — 활동 이력(created/assigned/completed/stage_changed). 메일 트리거 근거 + 타임라인. + +## 3. 화면 / 뷰 + +- **홈** (`/project/`): 프로젝트 카드 그리드 + "내 업무" 사이드. 관리자는 "새 프로젝트". +- **프로젝트** (`/project/p/{id}`): 좌측 서브프로젝트/멤버, 우측 뷰 토글. + - **달력** — FullCalendar. 업무를 시작~마감 기간으로 표시. 클릭 편집. + - **타임라인** — vis-timeline(간트형). 기간 있는 업무만. + - **보드** — 단계별 칸반. 카드 드래그로 단계 이동(`PUT /api/tasks/{id}` `stage_id`). + - **리스트** — 표. + - 라이브러리는 현재 CDN 로드(스켈레톤). 추후 `app/static/vendor/` self-host 권장. + +## 4. 메일 알림 + +- `app/mail.py` `send_email(...)` — `SMTP_HOST` 있어야 발송, 없으면 skip. STARTTLS/SSL/평문 지원. +- env: `SMTP_HOST/PORT/USER/PASSWORD/FROM/TLS`, 수신자 `PROJECT_NOTIFY_EMAIL`(쉼표, 비우면 admin 전원). +- 트리거: 업무 신규 배정(생성/수정 시 담당자 변경) · 완료단계 진입. `BackgroundTasks` 비동기, 실패는 로그만. + +## 5. 배포 + +```bash +# 1) DB 생성 (superuser 1회, 컨테이너 postgres-db) +read -s -p "project_app password: " APP_PWD; echo +docker exec -i postgres-db psql -U postgres -v app_password="$APP_PWD" \ + < scripts/sql/project_db_init.sql + +# 2) /opt/www/main/.env 에 등록 +# PROJECT_DB_URL=postgresql://project_app:@postgres-db:5432/project_db +# (메일 쓰려면 SMTP_* 추가) + +# 3) 재배포 (git pull 후 반드시 --build) +cd /opt/www/main && docker compose up -d --build web +``` + +## 6. 추후 단계 (스켈레톤 이후) + +댓글/첨부 UI · 태그 · 하위업무(체크리스트) · 검색/필터 · 아사나식 알림센터(인앱) · 단계 순서 드래그 편집 · 업무 정렬 영속화. diff --git a/scripts/sql/project_db_init.sql b/scripts/sql/project_db_init.sql new file mode 100644 index 0000000..90a2314 --- /dev/null +++ b/scripts/sql/project_db_init.sql @@ -0,0 +1,185 @@ +-- ===================================================================== +-- project_db 초기화 스크립트 (PostgreSQL) — 프로젝트 관리(아사나식) 모듈 +-- ===================================================================== +-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다. +-- +-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음. +-- +-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db): +-- +-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회) +-- read -s -p "project_app password: " APP_PWD; echo +-- docker exec -i postgres-db psql -U postgres \ +-- -v app_password="$APP_PWD" \ +-- < scripts/sql/project_db_init.sql +-- +-- 2) main-app .env 에 연결 정보 등록 +-- PROJECT_DB_URL=postgresql://project_app:@postgres-db:5432/project_db +-- +-- 3) main-app 재기동 +-- cd /opt/www/main && docker compose up -d --build +-- +-- 주의: +-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만). +-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- DB 가 없을 때만 생성 +SELECT 'CREATE DATABASE project_db ENCODING ''UTF8'' TEMPLATE template0' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'project_db') +\gexec + +-- 앱 전용 로그인 역할 (vacation_app / cupang_app 패턴과 동일) +SELECT 'CREATE ROLE project_app LOGIN PASSWORD ' || quote_literal(:'app_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'project_app') +\gexec + +-- 항상 최신 비밀번호로 동기화 +SELECT 'ALTER ROLE project_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password') +\gexec + +GRANT CONNECT ON DATABASE project_db TO project_app; + +-- project_db 컨텍스트로 전환 +\connect project_db + +-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ── +CREATE OR REPLACE FUNCTION project_set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ════════════════════════════════════════════════════════════ +-- 1) 프로젝트 / 서브프로젝트 (self-referential: parent_id) +-- parent_id NULL = 최상위 프로젝트, NOT NULL = 서브프로젝트. +-- 삭제는 CASCADE (서브프로젝트·단계·업무 모두 함께 제거). +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS projects ( + id BIGSERIAL PRIMARY KEY, + parent_id BIGINT REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#4573d2', + owner_email TEXT NOT NULL DEFAULT '', + start_date DATE, + due_date DATE, + status TEXT NOT NULL DEFAULT 'active', -- active | archived + sort_order INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_projects_parent ON projects (parent_id); +CREATE INDEX IF NOT EXISTS idx_projects_status ON projects (status); + +DROP TRIGGER IF EXISTS trg_projects_updated ON projects; +CREATE TRIGGER trg_projects_updated + BEFORE UPDATE ON projects + FOR EACH ROW EXECUTE FUNCTION project_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 2) 프로젝트 멤버 배정 (관리자가 사용자를 프로젝트에 지정) +-- role: manager(서브프로젝트/업무 관리) | member +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS project_members ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_email TEXT NOT NULL, + user_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'member', -- manager | member + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (project_id, user_email) +); +CREATE INDEX IF NOT EXISTS idx_project_members_project ON project_members (project_id); +CREATE INDEX IF NOT EXISTS idx_project_members_email ON project_members (user_email); + +-- ════════════════════════════════════════════════════════════ +-- 3) 진행 단계 (칸반 컬럼) — 프로젝트별. 생성 시 기본 4단계 seed. +-- is_done_stage: 이 단계로 옮기면 업무 '완료'로 간주(메일 트리거). +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS project_stages ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_done_stage BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_project_stages_project ON project_stages (project_id, sort_order); + +-- ════════════════════════════════════════════════════════════ +-- 4) 업무 (task) +-- stage_id 로 진행단계 표현. assignee_email 로 담당자 지정. +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS tasks ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + stage_id BIGINT REFERENCES project_stages(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + assignee_email TEXT NOT NULL DEFAULT '', + assignee_name TEXT NOT NULL DEFAULT '', + priority TEXT NOT NULL DEFAULT 'normal', -- low | normal | high + start_date DATE, + due_date DATE, + completed_at TIMESTAMPTZ, + sort_order INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks (project_id, sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_stage ON tasks (stage_id); +CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks (assignee_email); +CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks (due_date); + +DROP TRIGGER IF EXISTS trg_tasks_updated ON tasks; +CREATE TRIGGER trg_tasks_updated + BEFORE UPDATE ON tasks + FOR EACH ROW EXECUTE FUNCTION project_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 5) 업무 댓글 (아사나 기본기능 — 스켈레톤 단계 테이블) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS task_comments ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + author_email TEXT NOT NULL DEFAULT '', + author_name TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments (task_id, created_at); + +-- ════════════════════════════════════════════════════════════ +-- 6) 활동 이력 (배정/완료 등 — 메일 알림 근거 + 타임라인) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS project_activity ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE, + task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE, + actor_email TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, -- assigned | completed | created | stage_changed ... + detail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_project_activity_project ON project_activity (project_id, created_at DESC); + +-- ════════════════════════════════════════════════════════════ +-- 7) 권한 (project_app: CRUD only) +-- ════════════════════════════════════════════════════════════ +GRANT USAGE ON SCHEMA public TO project_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON + projects, project_members, project_stages, tasks, task_comments, project_activity + TO project_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO project_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO project_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO project_app; + +SELECT 'project_db ready' AS status;