feat(project): 세션 헤더 색상 채우기, 보드 댓글팝업, 채팅 첨부파일/이미지
- 세션 색상: 점 대신 세션 헤더 윗부분 배경을 통째로 칠함(글자/카운트 흰색으로) - 보드 업무 카드의 댓글 말풍선도 더블클릭하면 채팅 팝업 열리게(.pj-cmt-badge 공용) - 댓글 채팅 팝업에 파일/이미지 첨부 추가: 클립 버튼 업로드 + 클립보드 이미지 붙여넣기, 이미지는 썸네일, 파일은 확장자 아이콘+파일명으로 표시, 다운로드 가능, 업로드한 사람만 삭제(즉시 반영). task_attachments.comment_id(마이그 레이션 005)로 태깅해 업무 "첨부파일" 패널에도 그대로 함께 나온다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -683,11 +683,21 @@ class ProjectStore:
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get_comment(self, *, comment_id: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM task_comments WHERE id = %s", (comment_id,)
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
def add_comment(
|
||||
self, *, task_id: int, author_email: str, author_name: str, body: str
|
||||
self, *, task_id: int, author_email: str, author_name: str, body: str,
|
||||
allow_empty: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""allow_empty — 채팅 팝업에서 파일/이미지만 보내는 메시지(본문 없음)를
|
||||
허용할 때 True. 일반 댓글 입력은 여전히 빈 값을 막는다."""
|
||||
text = store.norm_str(body)
|
||||
if not text:
|
||||
if not text and not allow_empty:
|
||||
raise ValueError("댓글 내용이 비었습니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
@@ -744,15 +754,16 @@ class ProjectStore:
|
||||
def add_attachment(
|
||||
self, *, task_id: int, filename: str, stored_name: str,
|
||||
content_type: str, size_bytes: int, uploaded_by: str,
|
||||
comment_id: int | None = None,
|
||||
) -> 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, filename, stored_name, content_type, size_bytes, uploaded_by, comment_id) "
|
||||
"VALUES (%s,%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)),
|
||||
store.norm_str(uploaded_by, lower=True), comment_id),
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
|
||||
@@ -1164,6 +1164,7 @@ async def api_add_comment(
|
||||
author_email=user["email"],
|
||||
author_name=user.get("name") or user["email"].split("@")[0],
|
||||
body=payload.get("body", ""),
|
||||
allow_empty=bool(payload.get("has_attachment")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
@@ -1237,14 +1238,21 @@ async def api_list_attachments(request: Request, task_id: int) -> JSONResponse:
|
||||
|
||||
@router.post("/api/tasks/{task_id}/attachments")
|
||||
async def api_upload_attachment(
|
||||
request: Request, task_id: int, file: UploadFile = File(...)
|
||||
request: Request, task_id: int, file: UploadFile = File(...),
|
||||
comment_id: int | None = Form(None),
|
||||
) -> JSONResponse:
|
||||
"""comment_id 를 주면 댓글 채팅 팝업의 첨부(이미지/파일)로도 등록 —
|
||||
같은 표에 저장되므로 업무 "첨부파일" 패널에도 그대로 함께 나온다."""
|
||||
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"])
|
||||
if comment_id is not None:
|
||||
comment = st.get_comment(comment_id=comment_id)
|
||||
if comment is None or comment["task_id"] != task_id:
|
||||
raise HTTPException(status_code=400, detail="잘못된 댓글입니다.")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_ATTACH_BYTES:
|
||||
@@ -1261,7 +1269,7 @@ async def api_upload_attachment(
|
||||
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"],
|
||||
size_bytes=len(data), uploaded_by=user["email"], comment_id=comment_id,
|
||||
)
|
||||
return JSONResponse({"attachment": att}, status_code=201)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260917l" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260917m" />
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script src="/static/project.js?v=20260917q" defer></script>
|
||||
<script src="/static/project.js?v=20260917r" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260917l" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260917m" />
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -380,8 +380,13 @@
|
||||
</div>
|
||||
<input type="hidden" id="pj-cm-task-id" />
|
||||
<ul class="pj-chat-list" id="pj-chat-list"></ul>
|
||||
<div class="pj-chat-pending" id="pj-cm-pending" hidden></div>
|
||||
<div class="pj-comment-form">
|
||||
<input type="text" id="pj-cm-input" placeholder="댓글 입력…" />
|
||||
<label class="pj-chat-attach-btn" title="파일 추가">
|
||||
<span class="material-symbols-outlined">attach_file</span>
|
||||
<input type="file" id="pj-cm-file-input" multiple hidden />
|
||||
</label>
|
||||
<input type="text" id="pj-cm-input" placeholder="댓글 입력… (이미지는 붙여넣기 가능)" />
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,5 +398,5 @@
|
||||
<script>
|
||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||
</script>
|
||||
<script src="/static/project.js?v=20260917q" defer></script>
|
||||
<script src="/static/project.js?v=20260917r" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260917l" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260917m" />
|
||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
<!-- 타임라인 vis-timeline — self-host -->
|
||||
@@ -485,8 +485,13 @@
|
||||
</div>
|
||||
<input type="hidden" id="pj-cm-task-id" />
|
||||
<ul class="pj-chat-list" id="pj-chat-list"></ul>
|
||||
<div class="pj-chat-pending" id="pj-cm-pending" hidden></div>
|
||||
<div class="pj-comment-form">
|
||||
<input type="text" id="pj-cm-input" placeholder="댓글 입력…" />
|
||||
<label class="pj-chat-attach-btn" title="파일 추가">
|
||||
<span class="material-symbols-outlined">attach_file</span>
|
||||
<input type="file" id="pj-cm-file-input" multiple hidden />
|
||||
</label>
|
||||
<input type="text" id="pj-cm-input" placeholder="댓글 입력… (이미지는 붙여넣기 가능)" />
|
||||
<button type="button" class="pj-btn pj-btn-primary" id="pj-cm-send">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -533,5 +538,5 @@
|
||||
</script>
|
||||
|
||||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||||
<script src="/static/project.js?v=20260917q" defer></script>
|
||||
<script src="/static/project.js?v=20260917r" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user