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)), 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]) out[k] = int(out[k])
except (TypeError, ValueError): except (TypeError, ValueError):
pass 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: if k in out and out[k] is not None:
out[k] = bool(out[k]) out[k] = bool(out[k])
return out return out
+320 -3
View File
@@ -16,16 +16,36 @@ from __future__ import annotations
import calendar as _calendar import calendar as _calendar
import logging import logging
import uuid
from datetime import date from datetime import date
from pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request from fastapi import (
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse APIRouter,
BackgroundTasks,
Body,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
)
from fastapi.responses import (
FileResponse,
HTMLResponse,
JSONResponse,
RedirectResponse,
)
from app.timezone import today_kst from app.timezone import today_kst
from . import store from . import store
# 첨부 업로드 제한
_MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 20MB
logger = logging.getLogger("project.router") logger = logging.getLogger("project.router")
router = APIRouter(prefix="/project", tags=["project"]) router = APIRouter(prefix="/project", tags=["project"])
@@ -101,6 +121,39 @@ def _notify(
bg.add_task(send_email, to=recipients, subject=subject, body=body) 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: def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433 from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433 from app.store import is_admin # noqa: WPS433
@@ -605,7 +658,7 @@ async def api_create_task(
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
# 생성과 동시에 담당자 지정 시 관리자 알림 # 생성과 동시에 담당자 지정 시 관리자 메일 + 담당자 인앱 알림
if task.get("assignee_email"): if task.get("assignee_email"):
_notify( _notify(
bg, request, bg, request,
@@ -623,6 +676,12 @@ async def api_create_task(
f"등록자: {user['email']}\n" 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) return JSONResponse({"task": task}, status_code=201)
@@ -647,6 +706,7 @@ async def api_update_task(
raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.") raise HTTPException(status_code=404, detail="업무를 찾을 수 없습니다.")
project_name = task.get("project_name") or "" 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"): if task.get("_newly_assigned"):
_notify( _notify(
bg, request, bg, request,
@@ -664,6 +724,12 @@ async def api_update_task(
f"변경자: {user['email']}\n" 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"): if task.get("_newly_completed"):
_notify( _notify(
bg, request, bg, request,
@@ -677,6 +743,11 @@ async def api_update_task(
f"완료 처리: {user['email']}\n" 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}) return JSONResponse({"task": task})
@@ -697,3 +768,249 @@ async def api_activity(request: Request, project_id: int) -> JSONResponse:
_require_user(request) _require_user(request)
st = _db_or_503(request) st = _db_or_503(request)
return JSONResponse({"activity": st.list_activity(project_id=project_id, limit=100)}) 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" %} {% extends "erp_base.html" %}
{% block head_extra %} {% 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 %} {% endblock %}
{% block content %} {% block content %}
@@ -12,12 +13,18 @@
<section class="pj-home-main"> <section class="pj-home-main">
<div class="pj-section-head"> <div class="pj-section-head">
<h2>프로젝트</h2> <h2>프로젝트</h2>
<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 %} {% if is_admin %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-project"> <button type="button" class="pj-btn pj-btn-primary" id="pj-new-project">
+ 새 프로젝트 + 새 프로젝트
</button> </button>
{% endif %} {% endif %}
</div> </div>
</div>
{% if not tree %} {% if not tree %}
<div class="pj-empty"> <div class="pj-empty">
@@ -94,5 +101,5 @@
<script> <script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }}; window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script> </script>
<script src="/static/project.js?v=20260625a" defer></script> <script src="/static/project.js?v=20260625e" defer></script>
{% endblock %} {% endblock %}
@@ -1,11 +1,11 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625d" /> <link rel="stylesheet" href="/static/project.css?v=20260625e" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) --> <!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 달력은 휴가 모듈과 동일한 서버 렌더 그리드(project.css). 타임라인 vis(CDN, 추후 self-host) --> <!-- 타임라인 vis-timeline — self-host -->
<link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" /> <link href="/static/vendor/vis-timeline/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -74,10 +74,16 @@
<button type="button" class="pj-tab" data-view="board">보드</button> <button type="button" class="pj-tab" data-view="board">보드</button>
<button type="button" class="pj-tab" data-view="list">리스트</button> <button type="button" class="pj-tab" data-view="list">리스트</button>
</div> </div>
<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 %} {% if can_manage %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button> <button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button>
{% endif %} {% endif %}
</div> </div>
</div>
<!-- 달력 (휴가 모듈과 동일한 월간 그리드) --> <!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
<div class="pj-view" data-view="calendar"> <div class="pj-view" data-view="calendar">
@@ -181,6 +187,29 @@
<label>시작일<input type="date" id="pj-t-start" /></label> <label>시작일<input type="date" id="pj-t-start" /></label>
<label>마감일<input type="date" id="pj-t-due" /></label> <label>마감일<input type="date" id="pj-t-due" /></label>
</div> </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"> <div class="pj-modal-actions">
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-task" hidden>삭제</button> <button type="button" class="pj-btn pj-btn-danger" id="pj-delete-task" hidden>삭제</button>
<span style="flex:1"></span> <span style="flex:1"></span>
@@ -225,6 +254,6 @@
} }
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/standalone/umd/vis-timeline-graph2d.min.js"></script> <script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625d" defer></script> <script src="/static/project.js?v=20260625e" defer></script>
{% endblock %} {% endblock %}
+50
View File
@@ -159,3 +159,53 @@
.pj-color-palette, #pj-f-colors { display: flex; gap: 8px; margin-top: 6px; } .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 { 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; } .pj-color-swatch.is-active { border-color: #1e1f21; box-shadow: 0 0 0 2px #fff inset; }
/* ── 알림 벨 ── */
.pj-toolbar-right { display: flex; align-items: center; gap: 12px; }
.pj-bell { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 10px; color: #525860; text-decoration: none; }
.pj-bell:hover { background: #eceef0; }
.pj-bell-badge { position: absolute; top: 3px; right: 3px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: #e8384f; color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; }
/* ── 업무 모달 댓글/첨부 ── */
.pj-task-extra { border-top: 1px solid #eef0f2; margin-top: 14px; padding-top: 12px; }
.pj-extra-head { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: #525860; margin-bottom: 8px; }
.pj-upload-btn { margin-left: auto; font-size: 12px; font-weight: 600; color: #4573d2; cursor: pointer; padding: 3px 8px; border: 1px solid #cdd9f0; border-radius: 7px; }
.pj-upload-btn:hover { background: #eef3fc; }
.pj-attach-list, .pj-comment-list { list-style: none; margin: 0 0 6px; padding: 0; max-height: 160px; overflow-y: auto; }
.pj-attach-item { display: flex; align-items: center; gap: 8px; padding: 5px 4px; font-size: 13px; }
.pj-attach-item a { color: #2b4a85; text-decoration: none; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pj-attach-item a:hover { text-decoration: underline; }
.pj-attach-size { font-size: 11px; color: #9aa1a9; }
.pj-comment-item { display: flex; align-items: flex-start; gap: 8px; padding: 7px 2px; }
.pj-comment-main { flex: 1; }
.pj-comment-head { font-size: 12.5px; font-weight: 700; color: #1e1f21; }
.pj-comment-time { font-weight: 400; color: #9aa1a9; font-size: 11px; margin-left: 6px; }
.pj-comment-body { font-size: 13px; color: #333; margin-top: 1px; white-space: pre-wrap; }
.pj-comment-form { display: flex; gap: 6px; }
.pj-comment-form input { flex: 1; padding: 7px 10px; border: 1px solid #d8dce2; border-radius: 8px; font-size: 13px; font-family: inherit; }
/* ── 보드 단계 컨트롤 ── */
.pj-col-head { position: relative; }
.pj-col-name { font-weight: 700; }
.pj-col-ctrls { display: none; gap: 1px; margin-left: 6px; }
.pj-col:hover .pj-col-ctrls { display: inline-flex; }
.pj-col-ctrls .pj-icon-btn { width: 20px; height: 20px; font-size: 13px; }
.pj-icon-btn[disabled] { opacity: .3; cursor: default; }
.pj-col-add { background: transparent; flex: 0 0 180px; }
.pj-add-stage { width: 100%; padding: 10px; border: 1px dashed #c9cdd3; border-radius: 12px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; cursor: pointer; }
.pj-add-stage:hover { border-color: #1e1f21; color: #1e1f21; }
/* ── 알림센터 ── */
.pj-inbox { list-style: none; margin: 0; padding: 0; }
.pj-inbox-item { display: flex; align-items: flex-start; gap: 12px; padding: 14px 12px; border: 1px solid #eef0f2; border-radius: 12px; margin-bottom: 8px; cursor: pointer; background: #fff; }
.pj-inbox-item:hover { border-color: #d8dce2; }
.pj-inbox-item.is-unread { background: #f4f8ff; border-color: #d6e2fb; }
.pj-inbox-icon { color: #4573d2; font-size: 22px; flex: 0 0 auto; }
.pj-inbox-body { flex: 1; }
.pj-inbox-title { font-size: 14px; font-weight: 600; }
.pj-inbox-item.is-unread .pj-inbox-title::before { content: "●"; color: #4573d2; font-size: 9px; vertical-align: middle; margin-right: 6px; }
.pj-inbox-sub { font-size: 12.5px; color: #525860; margin-top: 2px; }
.pj-inbox-meta { font-size: 11px; color: #9aa1a9; margin-top: 4px; }
.pj-inbox-go { align-self: center; padding: 4px 12px; font-size: 12px; }
+197 -5
View File
@@ -171,26 +171,90 @@
}); });
} }
// ── 보드 (칸반) ── // ── 보드 (칸반) + 단계 편집 ──
function renderBoard() { function renderBoard() {
const board = el("pj-board"); const board = el("pj-board");
board.innerHTML = ""; board.innerHTML = "";
stages.forEach(function (stage) { stages.forEach(function (stage, idx) {
const col = document.createElement("div"); const col = document.createElement("div");
col.className = "pj-col"; col.className = "pj-col";
col.dataset.stageId = stage.id; col.dataset.stageId = stage.id;
let controls = "";
if (canManage) {
controls =
'<span class="pj-col-ctrls">' +
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="-1" title="왼쪽으로"' + (idx === 0 ? " disabled" : "") + '></button>' +
'<button type="button" class="pj-icon-btn pj-st-move" data-dir="1" title="오른쪽으로"' + (idx === stages.length - 1 ? " disabled" : "") + '></button>' +
'<button type="button" class="pj-icon-btn pj-st-rename" title="이름 변경">✎</button>' +
'<button type="button" class="pj-icon-btn pj-st-done" title="완료단계 토글">✓</button>' +
'<button type="button" class="pj-icon-btn pj-st-del" title="단계 삭제">×</button>' +
"</span>";
}
col.innerHTML = col.innerHTML =
'<div class="pj-col-head">' + escapeHtml(stage.name) + '<div class="pj-col-head"><span class="pj-col-name">' + escapeHtml(stage.name) + "</span>" +
(stage.is_done_stage ? ' <span class="pj-chip pj-chip-sm">완료</span>' : "") + (stage.is_done_stage ? ' <span class="pj-chip pj-chip-sm">완료</span>' : "") +
'<span class="pj-col-count"></span></div>' + '<span class="pj-col-count"></span>' + controls + "</div>" +
'<div class="pj-col-body"></div>'; '<div class="pj-col-body"></div>';
const body = col.querySelector(".pj-col-body"); const body = col.querySelector(".pj-col-body");
const colTasks = tasks.filter(function (t) { return t.stage_id === stage.id; }); const colTasks = tasks.filter(function (t) { return t.stage_id === stage.id; });
col.querySelector(".pj-col-count").textContent = colTasks.length; col.querySelector(".pj-col-count").textContent = colTasks.length;
colTasks.forEach(function (t) { body.appendChild(taskCard(t)); }); colTasks.forEach(function (t) { body.appendChild(taskCard(t)); });
if (canManage) enableDrop(body, stage.id); if (canManage) {
enableDrop(body, stage.id);
wireStageControls(col, stage, idx);
}
board.appendChild(col); board.appendChild(col);
}); });
if (canManage) {
const add = document.createElement("div");
add.className = "pj-col pj-col-add";
add.innerHTML = '<button type="button" class="pj-add-stage" id="pj-add-stage">+ 단계 추가</button>';
add.querySelector("#pj-add-stage").addEventListener("click", addStage);
board.appendChild(add);
}
}
async function reloadStages() {
const res = await api("GET", "/project/api/projects/" + projectId + "/stages");
stages = res.stages;
}
function wireStageControls(col, stage, idx) {
col.querySelectorAll(".pj-st-move").forEach(function (b) {
b.addEventListener("click", async function () {
const dir = parseInt(b.dataset.dir, 10);
const j = idx + dir;
if (j < 0 || j >= stages.length) return;
const ids = stages.map(function (s) { return s.id; });
const tmp = ids[idx]; ids[idx] = ids[j]; ids[j] = tmp;
try {
const res = await api("PUT", "/project/api/projects/" + projectId + "/stages/order", { ordered_ids: ids });
stages = res.stages; renderBoard();
} catch (e) { alert("이동 실패: " + e.message); }
});
});
col.querySelector(".pj-st-rename").addEventListener("click", async function () {
const name = prompt("단계 이름", stage.name);
if (!name || !name.trim()) return;
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { name: name.trim() }); await reloadStages(); renderBoard(); }
catch (e) { alert("변경 실패: " + e.message); }
});
col.querySelector(".pj-st-done").addEventListener("click", async function () {
try { await api("PUT", "/project/api/projects/" + projectId + "/stages/" + stage.id, { is_done_stage: !stage.is_done_stage }); await reloadStages(); renderBoard(); }
catch (e) { alert("변경 실패: " + e.message); }
});
col.querySelector(".pj-st-del").addEventListener("click", async function () {
if (!confirm("이 단계를 삭제할까요? (이 단계의 업무는 단계 미지정으로 남습니다)")) return;
try { await api("DELETE", "/project/api/projects/" + projectId + "/stages/" + stage.id); await reloadStages(); renderBoard(); }
catch (e) { alert("삭제 실패: " + e.message); }
});
}
async function addStage() {
const name = prompt("새 단계 이름");
if (!name || !name.trim()) return;
try { await api("POST", "/project/api/projects/" + projectId + "/stages", { name: name.trim() }); await reloadStages(); renderBoard(); }
catch (e) { alert("추가 실패: " + e.message); }
} }
function taskCard(t) { function taskCard(t) {
@@ -272,6 +336,7 @@
// ── 업무 모달 ── // ── 업무 모달 ──
const taskModal = el("pj-modal-task"); const taskModal = el("pj-modal-task");
wireModalClose(taskModal); wireModalClose(taskModal);
let currentTaskId = null;
function openTaskModal(task) { function openTaskModal(task) {
el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무"; el("pj-task-modal-title").textContent = task ? "업무 편집" : "새 업무";
@@ -284,9 +349,101 @@
el("pj-t-start").value = task ? (task.start_date || "") : ""; el("pj-t-start").value = task ? (task.start_date || "") : "";
el("pj-t-due").value = task ? (task.due_date || "") : ""; el("pj-t-due").value = task ? (task.due_date || "") : "";
el("pj-delete-task").hidden = !task; el("pj-delete-task").hidden = !task;
// 댓글·첨부는 기존 업무 편집 시에만
currentTaskId = task ? task.id : null;
el("pj-attach-block").hidden = !task;
el("pj-comment-block").hidden = !task;
if (task) { loadAttachments(task.id); loadComments(task.id); }
openModal(taskModal); openModal(taskModal);
} }
// ── 첨부파일 ──
function fmtSize(n) {
if (n < 1024) return n + " B";
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
return (n / 1048576).toFixed(1) + " MB";
}
async function loadAttachments(taskId) {
const ul = el("pj-attach-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/attachments");
if (!res.attachments.length) { ul.innerHTML = "<li class='pj-empty-sm'>없음</li>"; return; }
ul.innerHTML = "";
res.attachments.forEach(function (a) {
const li = document.createElement("li");
li.className = "pj-attach-item";
li.innerHTML =
'<a href="/project/api/attachments/' + a.id + '/download">' + escapeHtml(a.filename) + "</a>" +
'<span class="pj-attach-size">' + fmtSize(a.size_bytes) + "</span>" +
(canManage ? '<button type="button" class="pj-icon-btn pj-attach-del" data-id="' + a.id + '">×</button>' : "");
ul.appendChild(li);
});
ul.querySelectorAll(".pj-attach-del").forEach(function (b) {
b.addEventListener("click", async function () {
if (!confirm("이 첨부를 삭제할까요?")) return;
try { await api("DELETE", "/project/api/attachments/" + b.dataset.id); loadAttachments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
const attachInput = el("pj-attach-input");
if (attachInput) attachInput.addEventListener("change", async function () {
if (!currentTaskId || !attachInput.files.length) return;
const fd = new FormData();
fd.append("file", attachInput.files[0]);
try {
const res = await fetch("/project/api/tasks/" + currentTaskId + "/attachments", { method: "POST", body: fd });
if (!res.ok) { let m = res.statusText; try { m = (await res.json()).detail; } catch (_) {} throw new Error(m); }
attachInput.value = "";
loadAttachments(currentTaskId);
} catch (e) { alert("업로드 실패: " + e.message); }
});
// ── 댓글 ──
async function loadComments(taskId) {
const ul = el("pj-comment-list");
ul.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
try {
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
if (!res.comments.length) { ul.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>"; return; }
ul.innerHTML = "";
res.comments.forEach(function (c) {
const li = document.createElement("li");
li.className = "pj-comment-item";
li.innerHTML =
'<span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
'<div class="pj-comment-main"><div class="pj-comment-head">' +
escapeHtml(idOf(c.author_email)) + ' <span class="pj-comment-time">' +
(c.created_at || "").slice(0, 16).replace("T", " ") + "</span></div>" +
'<div class="pj-comment-body">' + escapeHtml(c.body) + "</div></div>" +
'<button type="button" class="pj-icon-btn pj-comment-del" data-id="' + c.id + '">×</button>';
ul.appendChild(li);
});
ul.querySelectorAll(".pj-comment-del").forEach(function (b) {
b.addEventListener("click", async function () {
try { await api("DELETE", "/project/api/comments/" + b.dataset.id); loadComments(taskId); }
catch (e) { alert("삭제 실패: " + e.message); }
});
});
} catch (e) { ul.innerHTML = "<li class='pj-empty-sm'>불러오기 실패</li>"; }
}
async function sendComment() {
const inp = el("pj-comment-input");
const body = inp.value.trim();
if (!currentTaskId || !body) return;
try {
await api("POST", "/project/api/tasks/" + currentTaskId + "/comments", { body: body });
inp.value = "";
loadComments(currentTaskId);
} catch (e) { alert("등록 실패: " + e.message); }
}
el("pj-comment-send").addEventListener("click", sendComment);
el("pj-comment-input").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); sendComment(); }
});
const newTaskBtn = el("pj-new-task"); const newTaskBtn = el("pj-new-task");
if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); }); if (newTaskBtn) newTaskBtn.addEventListener("click", function () { openTaskModal(null); });
@@ -404,8 +561,43 @@
.replace(/"/g, "&quot;").replace(/'/g, "&#39;"); .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
} }
// ── 알림 벨 (미읽음 배지) ──
async function initBell() {
const badge = document.getElementById("pj-bell-badge");
if (!badge) return;
try {
const res = await fetch("/project/api/notifications/unread-count");
if (!res.ok) return;
const d = await res.json();
if (d.unread > 0) { badge.textContent = d.unread > 99 ? "99+" : d.unread; badge.hidden = false; }
} catch (_) {}
}
// ── 알림센터 페이지 ──
function initInbox() {
const inbox = document.getElementById("pj-inbox");
const readAll = document.getElementById("pj-readall");
if (!inbox && !readAll) return;
if (readAll) readAll.addEventListener("click", async function () {
try { await fetch("/project/api/notifications/read-all", { method: "POST" }); location.reload(); }
catch (e) { alert("실패: " + e.message); }
});
if (inbox) inbox.querySelectorAll(".pj-inbox-item").forEach(function (li) {
li.addEventListener("click", async function (e) {
if (e.target.closest("a")) return; // '이동' 링크는 통과
if (!li.classList.contains("is-unread")) return;
try {
await fetch("/project/api/notifications/" + li.dataset.id + "/read", { method: "POST" });
li.classList.remove("is-unread");
} catch (_) {}
});
});
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
initHome(); initHome();
initProject(); initProject();
initBell();
initInbox();
}); });
})(); })();
+26
View File
@@ -0,0 +1,26 @@
/* Material Symbols Outlined — self-host (구글 CDN 대체).
원본: https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined
폰트 파일은 같은 폴더 material-symbols-outlined.woff2. */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
src: url(material-symbols-outlined.woff2) format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -56,6 +56,14 @@ docker exec -i postgres-db psql -U postgres -v app_password="$APP_PWD" \
cd /opt/www/main && docker compose up -d --build web cd /opt/www/main && docker compose up -d --build web
``` ```
## 6. 추후 단계 (스켈레톤 이후) ## 6. 댓글 · 첨부 · 알림센터 · 단계편집 (구현됨)
댓글/첨부 UI · 태그 · 하위업무(체크리스트) · 검색/필터 · 아사나식 알림센터(인앱) · 단계 순서 드래그 편집 · 업무 정렬 영속화. - **댓글** `task_comments` — 업무 모달 하단. 등록/삭제(본인·관리자). 새 댓글 시 관련자 인앱 알림.
- **첨부** `task_attachments` — 업무 모달. 파일 업로드(최대 20MB)/다운로드/삭제. 실제 파일은 `DATA_DIR/project/<task_id>/<uuid>.<ext>`, DB엔 메타만. 첨부 디렉토리는 운영 볼륨(DATA_DIR)에 저장돼 재배포에도 보존.
- **단계 편집** — 보드 칸반 헤더에서 단계 이름변경/완료토글/좌우 이동(순서)/삭제, 트레일링 "+ 단계 추가". `PUT .../stages/order`, `PUT .../stages/{id}`.
- **알림센터(인앱)** `project_notifications` — 배정/완료/댓글 시 수신자별 알림 생성. 우측 상단 벨(미읽음 배지) → `/project/inbox`. 항목 클릭=읽음, "모두 읽음". 본인 행동은 알림 제외.
- 마이그레이션: `scripts/sql/project_db_002_attachments_notifications.sql` (첨부·알림 테이블 + 권한).
## 7. 추후 단계
태그 · 하위업무(체크리스트) · 검색/필터 · 업무 정렬 영속화 · 칸반 단계 드래그 정렬 · 멘션.
@@ -0,0 +1,57 @@
-- =====================================================================
-- project_db 마이그레이션 002 — 첨부파일 + 알림센터
-- =====================================================================
-- 멱등(idempotent). DROP/TRUNCATE 없음.
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d project_db \
-- < scripts/sql/project_db_002_attachments_notifications.sql
--
-- (task_comments 는 init 스크립트에 이미 있음 — 여기서 다루지 않음)
-- =====================================================================
\set ON_ERROR_STOP on
\connect project_db
-- ════════════════════════════════════════════════════════════
-- 업무 첨부파일 — 실제 파일은 DATA_DIR/project/<task_id>/ 에 저장.
-- 여기엔 메타데이터만(원본명/저장명/크기/타입/업로더).
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS task_attachments (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
filename TEXT NOT NULL, -- 사용자에게 보일 원본 파일명
stored_name TEXT NOT NULL, -- 디스크 저장명(UUID.ext)
content_type TEXT NOT NULL DEFAULT '',
size_bytes BIGINT NOT NULL DEFAULT 0,
uploaded_by TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_task_attachments_task ON task_attachments (task_id, created_at);
-- ════════════════════════════════════════════════════════════
-- 알림센터 — 사용자별 인앱 알림(아사나 Inbox).
-- 업무 배정/완료/댓글/단계변경 시 관련자에게 1행씩 생성.
-- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS project_notifications (
id BIGSERIAL PRIMARY KEY,
recipient_email TEXT NOT NULL,
actor_email TEXT NOT NULL DEFAULT '',
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- assigned | completed | comment | stage_changed
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_project_notif_recipient
ON project_notifications (recipient_email, is_read, created_at DESC);
-- ════════════════════════════════════════════════════════════
-- 권한 (project_app: CRUD only)
-- ════════════════════════════════════════════════════════════
GRANT SELECT, INSERT, UPDATE, DELETE ON task_attachments, project_notifications TO project_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO project_app;
SELECT 'project_db 002 ready' AS status;