feat(project): 댓글·첨부·단계편집·알림센터 + 라이브러리 self-host

아사나식 기능 4종 추가:
- 댓글: 업무 모달에서 등록/삭제, 관련자 인앱 알림(task_comments)
- 첨부: 업로드(20MB)/다운로드/삭제, 파일은 DATA_DIR/project/<task_id>/,
  DB엔 메타만(task_attachments)
- 단계 편집: 보드 칸반에서 이름변경/완료토글/순서이동/삭제/추가
- 알림센터(인앱): 배정·완료·댓글 시 수신자별 알림, 벨 미읽음 배지,
  /project/inbox 페이지, 읽음/모두읽음(project_notifications)

라이브러리 self-host: vis-timeline + Material Symbols 를 static/vendor/ 로
내려받아 CDN 의존 제거.

DB: scripts/sql/project_db_002_attachments_notifications.sql (서버서 적용 필요).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 12:32:00 +09:00
parent ef7f76821d
commit 776f655db4
13 changed files with 1002 additions and 28 deletions
+190 -1
View File
@@ -494,6 +494,195 @@ class ProjectStore:
store.norm_str(detail)),
)
# ════════════════════════════════════════════════════════════
# 댓글 (task_comments)
# ════════════════════════════════════════════════════════════
def list_comments(self, *, task_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM task_comments WHERE task_id = %s "
"ORDER BY created_at ASC, id ASC",
(task_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_comment(
self, *, task_id: int, author_email: str, author_name: str, body: str
) -> dict[str, Any]:
text = store.norm_str(body)
if not text:
raise ValueError("댓글 내용이 비었습니다.")
with self._pool.connection() as conn:
row = conn.execute(
"INSERT INTO task_comments (task_id, author_email, author_name, body) "
"VALUES (%s,%s,%s,%s) RETURNING *",
(task_id, store.norm_str(author_email, lower=True),
store.norm_str(author_name), text),
).fetchone()
return self._serialize(row)
def delete_comment(self, *, comment_id: int, requester: str, is_admin: bool) -> None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT author_email FROM task_comments WHERE id = %s", (comment_id,)
).fetchone()
if not row:
raise KeyError(comment_id)
if not is_admin and row["author_email"] != store.norm_str(requester, lower=True):
raise PermissionError("본인 댓글만 삭제할 수 있습니다.")
conn.execute("DELETE FROM task_comments WHERE id = %s", (comment_id,))
# ════════════════════════════════════════════════════════════
# 첨부 (task_attachments) — 메타만 저장. 파일은 라우터가 디스크 처리.
# ════════════════════════════════════════════════════════════
def list_attachments(self, *, task_id: int) -> list[dict[str, Any]]:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT * FROM task_attachments WHERE task_id = %s "
"ORDER BY created_at DESC, id DESC",
(task_id,),
).fetchall()
return [self._serialize(r) for r in rows]
def add_attachment(
self, *, task_id: int, filename: str, stored_name: str,
content_type: str, size_bytes: int, uploaded_by: str,
) -> dict[str, Any]:
with self._pool.connection() as conn:
row = conn.execute(
"INSERT INTO task_attachments "
"(task_id, filename, stored_name, content_type, size_bytes, uploaded_by) "
"VALUES (%s,%s,%s,%s,%s,%s) RETURNING *",
(task_id, store.norm_str(filename), stored_name,
store.norm_str(content_type), int(size_bytes),
store.norm_str(uploaded_by, lower=True)),
).fetchone()
return self._serialize(row)
def get_attachment(self, *, attachment_id: int) -> dict[str, Any] | None:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT * FROM task_attachments WHERE id = %s", (attachment_id,)
).fetchone()
return self._serialize(row) if row else None
def delete_attachment(self, *, attachment_id: int) -> dict[str, Any]:
"""삭제 후 행(파일 경로 정리용 stored_name 포함)을 반환."""
with self._pool.connection() as conn:
row = conn.execute(
"DELETE FROM task_attachments WHERE id = %s RETURNING *", (attachment_id,)
).fetchone()
if not row:
raise KeyError(attachment_id)
return self._serialize(row)
# ════════════════════════════════════════════════════════════
# 단계 순서/이름 편집
# ════════════════════════════════════════════════════════════
def update_stage(
self, *, stage_id: int, name: str | None = None,
is_done_stage: bool | None = None,
) -> dict[str, Any]:
sets: list[str] = []
params: list[Any] = []
if name is not None:
nm = store.norm_str(name)
if not nm:
raise ValueError("단계 이름은 필수입니다.")
sets.append("name = %s")
params.append(nm)
if is_done_stage is not None:
sets.append("is_done_stage = %s")
params.append(bool(is_done_stage))
if not sets:
raise ValueError("변경할 내용이 없습니다.")
params.append(stage_id)
with self._pool.connection() as conn:
row = conn.execute(
f"UPDATE project_stages SET {', '.join(sets)} WHERE id = %s RETURNING *",
params,
).fetchone()
if not row:
raise KeyError(stage_id)
return self._serialize(row)
def reorder_stages(self, *, project_id: int, ordered_ids: list[int]) -> None:
"""주어진 순서대로 sort_order 재배정. 해당 프로젝트 단계만 갱신."""
with self._pool.connection() as conn:
with conn.transaction():
for i, sid in enumerate(ordered_ids):
conn.execute(
"UPDATE project_stages SET sort_order = %s "
"WHERE id = %s AND project_id = %s",
(i, int(sid), project_id),
)
# ════════════════════════════════════════════════════════════
# 알림센터 (project_notifications)
# ════════════════════════════════════════════════════════════
def add_notification(
self, *, recipient_email: str, actor_email: str = "",
project_id: int | None = None, task_id: int | None = None,
type: str = "", title: str = "", body: str = "",
) -> None:
rcpt = store.norm_str(recipient_email, lower=True)
if not rcpt:
return
# 본인이 본인에게 보내는 알림은 생략(아사나도 자기 행동은 알림 안 함)
if rcpt == store.norm_str(actor_email, lower=True):
return
with self._pool.connection() as conn:
conn.execute(
"INSERT INTO project_notifications "
"(recipient_email, actor_email, project_id, task_id, type, title, body) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(rcpt, store.norm_str(actor_email, lower=True), project_id, task_id,
type, store.norm_str(title), store.norm_str(body)),
)
def list_notifications(
self, *, recipient_email: str, unread_only: bool = False, limit: int = 100
) -> list[dict[str, Any]]:
rcpt = store.norm_str(recipient_email, lower=True)
clause = "WHERE recipient_email = %s"
params: list[Any] = [rcpt]
if unread_only:
clause += " AND is_read = FALSE"
params.append(int(limit))
with self._pool.connection() as conn:
rows = conn.execute(
f"SELECT * FROM project_notifications {clause} "
"ORDER BY created_at DESC, id DESC LIMIT %s",
params,
).fetchall()
return [self._serialize(r) for r in rows]
def count_unread(self, *, recipient_email: str) -> int:
with self._pool.connection() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM project_notifications "
"WHERE recipient_email = %s AND is_read = FALSE",
(store.norm_str(recipient_email, lower=True),),
).fetchone()
return int(row["n"] or 0)
def mark_read(self, *, notification_id: int, recipient_email: str) -> None:
with self._pool.connection() as conn:
conn.execute(
"UPDATE project_notifications SET is_read = TRUE "
"WHERE id = %s AND recipient_email = %s",
(notification_id, store.norm_str(recipient_email, lower=True)),
)
def mark_all_read(self, *, recipient_email: str) -> int:
with self._pool.connection() as conn:
cur = conn.execute(
"UPDATE project_notifications SET is_read = TRUE "
"WHERE recipient_email = %s AND is_read = FALSE",
(store.norm_str(recipient_email, lower=True),),
)
return cur.rowcount or 0
# ════════════════════════════════════════════════════════════
# 직렬화
# ════════════════════════════════════════════════════════════
@@ -513,7 +702,7 @@ class ProjectStore:
out[k] = int(out[k])
except (TypeError, ValueError):
pass
for k in ("is_done_stage",):
for k in ("is_done_stage", "is_read"):
if k in out and out[k] is not None:
out[k] = bool(out[k])
return out
+320 -3
View File
@@ -16,16 +16,36 @@ from __future__ import annotations
import calendar as _calendar
import logging
import uuid
from datetime import date
from pathlib import Path
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi import (
APIRouter,
BackgroundTasks,
Body,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
)
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
)
from app.timezone import today_kst
from . import store
# 첨부 업로드 제한
_MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 20MB
logger = logging.getLogger("project.router")
router = APIRouter(prefix="/project", tags=["project"])
@@ -101,6 +121,39 @@ def _notify(
bg.add_task(send_email, to=recipients, subject=subject, body=body)
def _task_recipients(
st: Any, project: dict[str, Any], task: dict[str, Any], *, exclude: str = ""
) -> list[str]:
"""업무 관련 인앱 알림 수신자 = 담당자 + 프로젝트 owner + 멤버 (행위자 제외)."""
emails: set[str] = set()
if task.get("assignee_email"):
emails.add(store.norm_str(task["assignee_email"], lower=True))
if project.get("owner_email"):
emails.add(store.norm_str(project["owner_email"], lower=True))
try:
for m in st.list_members(project_id=project["id"]):
emails.add(store.norm_str(m["user_email"], lower=True))
except Exception: # noqa: BLE001
pass
emails.discard(store.norm_str(exclude, lower=True))
return [e for e in emails if e]
def _inbox(
st: Any, *, recipients: list[str], actor: str, project_id: int | None,
task_id: int | None, ntype: str, title: str, body: str = "",
) -> None:
"""인앱 알림(알림센터) 생성. 실패해도 본 작업을 막지 않는다."""
for r in recipients:
try:
st.add_notification(
recipient_email=r, actor_email=actor, project_id=project_id,
task_id=task_id, type=ntype, title=title, body=body,
)
except Exception: # noqa: BLE001
logger.warning("알림 생성 실패: %s", r)
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
@@ -605,7 +658,7 @@ async def api_create_task(
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# 생성과 동시에 담당자 지정 시 관리자 알림
# 생성과 동시에 담당자 지정 시 관리자 메일 + 담당자 인앱 알림
if task.get("assignee_email"):
_notify(
bg, request,
@@ -623,6 +676,12 @@ async def api_create_task(
f"등록자: {user['email']}\n"
),
)
_inbox(
st, recipients=[task["assignee_email"]], actor=user["email"],
project_id=project_id, task_id=task["id"], ntype="assigned",
title=f"업무 배정: {task['title']}",
body=f"{project['name']} · 마감 {task.get('due_date') or '-'}",
)
return JSONResponse({"task": task}, status_code=201)
@@ -647,6 +706,7 @@ async def api_update_task(
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
project_name = task.get("project_name") or ""
project = st.get_project(project_id=existing["project_id"]) or {"id": existing["project_id"], "name": project_name}
if task.get("_newly_assigned"):
_notify(
bg, request,
@@ -664,6 +724,12 @@ async def api_update_task(
f"변경자: {user['email']}\n"
),
)
_inbox(
st, recipients=[task["_newly_assigned"]], actor=user["email"],
project_id=existing["project_id"], task_id=task_id, ntype="assigned",
title=f"업무 배정: {task['title']}",
body=f"{project_name} · 마감 {task.get('due_date') or '-'}",
)
if task.get("_newly_completed"):
_notify(
bg, request,
@@ -677,6 +743,11 @@ async def api_update_task(
f"완료 처리: {user['email']}\n"
),
)
_inbox(
st, recipients=_task_recipients(st, project, task, exclude=user["email"]),
actor=user["email"], project_id=existing["project_id"], task_id=task_id,
ntype="completed", title=f"업무 완료: {task['title']}", body=project_name,
)
return JSONResponse({"task": task})
@@ -697,3 +768,249 @@ 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)})
# ────────────────────────────────────────────────────────────
# JSON API — 댓글
# ────────────────────────────────────────────────────────────
@router.get("/api/tasks/{task_id}/comments")
async def api_list_comments(request: Request, task_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
return JSONResponse({"comments": st.list_comments(task_id=task_id)})
@router.post("/api/tasks/{task_id}/comments")
async def api_add_comment(
request: Request, task_id: int, payload: dict[str, Any] = Body(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
try:
comment = st.add_comment(
task_id=task_id,
author_email=user["email"],
author_name=user.get("name") or user["email"].split("@")[0],
body=payload.get("body", ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
project = st.get_project(project_id=task["project_id"]) or {"id": task["project_id"], "name": task.get("project_name", "")}
_inbox(
st, recipients=_task_recipients(st, project, task, exclude=user["email"]),
actor=user["email"], project_id=task["project_id"], task_id=task_id,
ntype="comment", title=f"새 댓글: {task['title']}",
body=comment["body"][:120],
)
return JSONResponse({"comment": comment}, status_code=201)
@router.delete("/api/comments/{comment_id}")
async def api_delete_comment(request: Request, comment_id: int) -> JSONResponse:
from app.store import is_admin # noqa: WPS433
user = _require_user(request)
st = _db_or_503(request)
try:
st.delete_comment(comment_id=comment_id, requester=user["email"], is_admin=is_admin(user))
except KeyError:
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
return JSONResponse({"ok": True})
# ────────────────────────────────────────────────────────────
# JSON API — 첨부파일
# ────────────────────────────────────────────────────────────
def _attach_dir(request: Request, task_id: int) -> Path:
base = Path(str(getattr(request.app.state, "data_dir", ".")))
d = base / "project" / str(task_id)
d.mkdir(parents=True, exist_ok=True)
return d
@router.get("/api/tasks/{task_id}/attachments")
async def api_list_attachments(request: Request, task_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
return JSONResponse({"attachments": st.list_attachments(task_id=task_id)})
@router.post("/api/tasks/{task_id}/attachments")
async def api_upload_attachment(
request: Request, task_id: int, file: UploadFile = File(...)
) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
task = st.get_task(task_id=task_id)
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
data = await file.read()
if len(data) > _MAX_ATTACH_BYTES:
raise HTTPException(status_code=413, detail="파일이 너무 큽니다(최대 20MB).")
if not data:
raise HTTPException(status_code=400, detail="빈 파일입니다.")
orig = (file.filename or "file").replace("\\", "_").replace("/", "_").strip()
suffix = Path(orig).suffix[:16]
stored = f"{uuid.uuid4().hex}{suffix}"
dest = _attach_dir(request, task_id) / stored
dest.write_bytes(data)
att = st.add_attachment(
task_id=task_id, filename=orig, stored_name=stored,
content_type=file.content_type or "application/octet-stream",
size_bytes=len(data), uploaded_by=user["email"],
)
return JSONResponse({"attachment": att}, status_code=201)
@router.get("/api/attachments/{attachment_id}/download")
async def api_download_attachment(request: Request, attachment_id: int):
user = _require_user(request)
st = _db_or_503(request)
att = st.get_attachment(attachment_id=attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="첨부를 찾을 수 없습니다.")
task = st.get_task(task_id=att["task_id"])
if task is None:
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
_require_manage(request, st, user, task["project_id"])
path = _attach_dir(request, att["task_id"]) / att["stored_name"]
if not path.exists():
raise HTTPException(status_code=404, detail="파일이 디스크에 없습니다.")
return FileResponse(
str(path), filename=att["filename"],
media_type=att.get("content_type") or "application/octet-stream",
)
@router.delete("/api/attachments/{attachment_id}")
async def api_delete_attachment(request: Request, attachment_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
att = st.get_attachment(attachment_id=attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="첨부를 찾을 수 없습니다.")
task = st.get_task(task_id=att["task_id"])
if task is not None:
_require_manage(request, st, user, task["project_id"])
removed = st.delete_attachment(attachment_id=attachment_id)
try:
(_attach_dir(request, removed["task_id"]) / removed["stored_name"]).unlink(missing_ok=True)
except Exception: # noqa: BLE001
logger.warning("첨부 파일 삭제 실패: %s", removed.get("stored_name"))
return JSONResponse({"ok": True})
# ────────────────────────────────────────────────────────────
# JSON API — 단계 이름/순서 편집
# ────────────────────────────────────────────────────────────
@router.put("/api/projects/{project_id}/stages/order")
async def api_reorder_stages(
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)
ids = payload.get("ordered_ids") or []
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="ordered_ids 가 필요합니다.")
st.reorder_stages(project_id=project_id, ordered_ids=[int(i) for i in ids])
return JSONResponse({"stages": st.list_stages(project_id=project_id)})
@router.put("/api/projects/{project_id}/stages/{stage_id}")
async def api_update_stage(
request: Request, project_id: int, stage_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.update_stage(
stage_id=stage_id,
name=payload.get("name"),
is_done_stage=payload.get("is_done_stage"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except KeyError:
raise HTTPException(status_code=404, detail="단계를 찾을 수 없습니다.")
return JSONResponse({"stage": stage})
# ────────────────────────────────────────────────────────────
# 알림센터 (인앱)
# ────────────────────────────────────────────────────────────
@router.get("/api/notifications")
async def api_notifications(request: Request) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
unread_only = request.query_params.get("unread") in ("1", "true")
items = st.list_notifications(
recipient_email=user["email"], unread_only=unread_only, limit=100
)
return JSONResponse({"notifications": items, "unread": st.count_unread(recipient_email=user["email"])})
@router.get("/api/notifications/unread-count")
async def api_notif_count(request: Request) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
return JSONResponse({"unread": st.count_unread(recipient_email=user["email"])})
@router.post("/api/notifications/{notification_id}/read")
async def api_notif_read(request: Request, notification_id: int) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
st.mark_read(notification_id=notification_id, recipient_email=user["email"])
return JSONResponse({"ok": True})
@router.post("/api/notifications/read-all")
async def api_notif_read_all(request: Request) -> JSONResponse:
user = _require_user(request)
st = _db_or_503(request)
n = st.mark_all_read(recipient_email=user["email"])
return JSONResponse({"ok": True, "marked": n})
@router.get("/inbox", response_class=HTMLResponse)
async def inbox_page(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)
items = st.list_notifications(recipient_email=user["email"], limit=100)
return render_template(
request,
"project/inbox.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="project"),
"page_title": "알림센터",
"page_subtitle": "내게 온 프로젝트 알림",
"notifications": items,
},
)
@@ -0,0 +1,49 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625e" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
{% block content %}
<div class="pj-wrap">
<div class="pj-section-head">
<h2>알림센터</h2>
<div style="display:flex; gap:8px;">
<a class="pj-btn" href="/project/">← 프로젝트</a>
<button type="button" class="pj-btn pj-btn-primary" id="pj-readall">모두 읽음</button>
</div>
</div>
{% if not notifications %}
<div class="pj-empty">알림이 없습니다.</div>
{% else %}
<ul class="pj-inbox" id="pj-inbox">
{% for n in notifications %}
<li class="pj-inbox-item {% if not n.is_read %}is-unread{% endif %}"
data-id="{{ n.id }}"
{% if n.task_id %}data-project="{{ n.project_id }}"{% endif %}>
<span class="material-symbols-outlined pj-inbox-icon">
{% if n.type == 'assigned' %}assignment_ind
{% elif n.type == 'completed' %}task_alt
{% elif n.type == 'comment' %}chat_bubble
{% else %}notifications{% endif %}
</span>
<div class="pj-inbox-body">
<div class="pj-inbox-title">{{ n.title }}</div>
{% if n.body %}<div class="pj-inbox-sub">{{ n.body }}</div>{% endif %}
<div class="pj-inbox-meta">
{% if n.actor_email %}{{ n.actor_email.split('@')[0] }} · {% endif %}{{ n.created_at[:16].replace('T',' ') }}
</div>
</div>
{% if n.project_id %}
<a class="pj-btn pj-inbox-go" href="/project/p/{{ n.project_id }}">이동</a>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<script src="/static/project.js?v=20260625e" defer></script>
{% endblock %}
@@ -1,7 +1,8 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
<link rel="stylesheet" href="/static/project.css?v=20260625e" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %}
{% block content %}
@@ -12,11 +13,17 @@
<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 class="pj-toolbar-right">
<a class="pj-bell" href="/project/inbox" title="알림센터">
<span class="material-symbols-outlined">notifications</span>
<span class="pj-bell-badge" id="pj-bell-badge" hidden>0</span>
</a>
{% if is_admin %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-project">
+ 새 프로젝트
</button>
{% endif %}
</div>
</div>
{% if not tree %}
@@ -94,5 +101,5 @@
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260625a" defer></script>
<script src="/static/project.js?v=20260625e" defer></script>
{% endblock %}
@@ -1,11 +1,11 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625d" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
<!-- 달력은 휴가 모듈과 동일한 서버 렌더 그리드(project.css). 타임라인 vis(CDN, 추후 self-host) -->
<link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/static/project.css?v=20260625e" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host -->
<link href="/static/vendor/vis-timeline/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %}
{% block content %}
@@ -74,9 +74,15 @@
<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 class="pj-toolbar-right">
<a class="pj-bell" href="/project/inbox" title="알림센터">
<span class="material-symbols-outlined">notifications</span>
<span class="pj-bell-badge" id="pj-bell-badge" hidden>0</span>
</a>
{% if can_manage %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button>
{% endif %}
</div>
</div>
<!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
@@ -181,6 +187,29 @@
<label>시작일<input type="date" id="pj-t-start" /></label>
<label>마감일<input type="date" id="pj-t-due" /></label>
</div>
<!-- 첨부파일 (기존 업무 편집 시에만) -->
<div class="pj-task-extra" id="pj-attach-block" hidden>
<div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">attach_file</span> 첨부파일
<label class="pj-upload-btn">
파일 추가<input type="file" id="pj-attach-input" hidden />
</label>
</div>
<ul class="pj-attach-list" id="pj-attach-list"></ul>
</div>
<!-- 댓글 (기존 업무 편집 시에만) -->
<div class="pj-task-extra" id="pj-comment-block" hidden>
<div class="pj-extra-head">
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">chat_bubble</span> 댓글
</div>
<ul class="pj-comment-list" id="pj-comment-list"></ul>
<div class="pj-comment-form">
<input type="text" id="pj-comment-input" placeholder="댓글 입력…" />
<button type="button" class="pj-btn pj-btn-primary" id="pj-comment-send">등록</button>
</div>
</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>
@@ -225,6 +254,6 @@
}
</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=20260625d" defer></script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625e" defer></script>
{% endblock %}