feat(project): 업무 시간 지정(종일 기본) + 타임라인 시간 반영

- tasks 에 start_time/due_time TIME 컬럼 추가(마이그레이션 003). NULL=종일
- 업무 모달에 '시간 지정' 체크박스 → 체크 시 시작/마감 시간 입력란 표시.
  미체크면 종일로 저장(시간 null)
- store.validate_time(HH:MM), db create/update + TIME 직렬화('HH:MM')
- 타임라인: 시간 있으면 date+time 으로 정확히 배치, 없으면 종일

DB: scripts/sql/project_db_003_task_times.sql 서버서 적용 필요.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 13:34:52 +09:00
parent a6adb5fc41
commit 133bfc55b8
9 changed files with 90 additions and 13 deletions
+17 -4
View File
@@ -13,7 +13,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import date, datetime from datetime import date, datetime, time
from typing import Any from typing import Any
from psycopg.rows import dict_row from psycopg.rows import dict_row
@@ -331,12 +331,16 @@ class ProjectStore:
priority: str = "normal", priority: str = "normal",
start_date: str | None = None, start_date: str | None = None,
due_date: str | None = None, due_date: str | None = None,
start_time: str | None = None,
due_time: str | None = None,
created_by: str = "", created_by: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
ttl = store.validate_task_title(title) ttl = store.validate_task_title(title)
pr = store.validate_priority(priority) pr = store.validate_priority(priority)
sd = store.validate_date(start_date) sd = store.validate_date(start_date)
dd = store.validate_date(due_date) dd = store.validate_date(due_date)
sti = store.validate_time(start_time)
dti = store.validate_time(due_time)
assignee = store.norm_str(assignee_email, lower=True) assignee = store.norm_str(assignee_email, lower=True)
with self._pool.connection() as conn: with self._pool.connection() as conn:
with conn.transaction(): with conn.transaction():
@@ -357,14 +361,15 @@ class ProjectStore:
""" """
INSERT INTO tasks INSERT INTO tasks
(project_id, stage_id, title, description, assignee_email, (project_id, stage_id, title, description, assignee_email,
assignee_name, priority, start_date, due_date, sort_order, created_by) assignee_name, priority, start_date, due_date,
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) start_time, due_time, sort_order, created_by)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING * RETURNING *
""", """,
( (
project_id, stage_id, ttl, store.norm_str(description), project_id, stage_id, ttl, store.norm_str(description),
assignee, store.norm_str(assignee_name), pr, sd, dd, assignee, store.norm_str(assignee_name), pr, sd, dd,
int(nxt["n"]), store.norm_str(created_by, lower=True), sti, dti, int(nxt["n"]), store.norm_str(created_by, lower=True),
), ),
).fetchone() ).fetchone()
if assignee: if assignee:
@@ -406,6 +411,12 @@ class ProjectStore:
if "due_date" in fields: if "due_date" in fields:
sets.append("due_date = %s") sets.append("due_date = %s")
params.append(store.validate_date(fields["due_date"])) params.append(store.validate_date(fields["due_date"]))
if "start_time" in fields:
sets.append("start_time = %s")
params.append(store.validate_time(fields["start_time"]))
if "due_time" in fields:
sets.append("due_time = %s")
params.append(store.validate_time(fields["due_time"]))
if "assignee_email" in fields: if "assignee_email" in fields:
new_assignee = store.norm_str(fields["assignee_email"], lower=True) new_assignee = store.norm_str(fields["assignee_email"], lower=True)
sets.append("assignee_email = %s") sets.append("assignee_email = %s")
@@ -714,6 +725,8 @@ class ProjectStore:
for k, v in list(out.items()): for k, v in list(out.items()):
if isinstance(v, datetime): if isinstance(v, datetime):
out[k] = v.astimezone(KST).isoformat(timespec="seconds") out[k] = v.astimezone(KST).isoformat(timespec="seconds")
elif isinstance(v, time):
out[k] = v.strftime("%H:%M")
elif isinstance(v, date): elif isinstance(v, date):
out[k] = v.isoformat() out[k] = v.isoformat()
for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order"): for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order"):
+2
View File
@@ -681,6 +681,8 @@ async def api_create_task(
priority=payload.get("priority", "normal"), priority=payload.get("priority", "normal"),
start_date=payload.get("start_date"), start_date=payload.get("start_date"),
due_date=payload.get("due_date"), due_date=payload.get("due_date"),
start_time=payload.get("start_time"),
due_time=payload.get("due_time"),
created_by=user["email"], created_by=user["email"],
) )
except ValueError as exc: except ValueError as exc:
+16
View File
@@ -108,6 +108,22 @@ def validate_date(d: Any) -> str | None:
return v[:10] return v[:10]
def validate_time(t: Any) -> str | None:
"""'HH:MM' 형식만 통과(초는 버림). 빈값/None 은 None(=종일)."""
v = norm_str(t)
if not v:
return None
parts = v.split(":")
try:
hh = int(parts[0])
mm = int(parts[1]) if len(parts) > 1 else 0
except (ValueError, IndexError):
raise ValueError(f"시간 형식이 올바르지 않습니다: {t}")
if not (0 <= hh <= 23 and 0 <= mm <= 59):
raise ValueError(f"시간 범위가 올바르지 않습니다: {t}")
return f"{hh:02d}:{mm:02d}"
def build_subject_assigned(*, project_name: str, task_title: str, assignee: str) -> str: def build_subject_assigned(*, project_name: str, task_title: str, assignee: str) -> str:
return f"[DBX 프로젝트] 업무 배정: {project_name} · {task_title}{assignee}" return f"[DBX 프로젝트] 업무 배정: {project_name} · {task_title}{assignee}"
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625l" /> <link rel="stylesheet" href="/static/project.css?v=20260625m" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -45,5 +45,5 @@
{% endif %} {% endif %}
</div> </div>
<script src="/static/project.js?v=20260625l" defer></script> <script src="/static/project.js?v=20260625m" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625l" /> <link rel="stylesheet" href="/static/project.css?v=20260625m" />
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
{% endblock %} {% endblock %}
@@ -100,5 +100,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=20260625l" defer></script> <script src="/static/project.js?v=20260625m" defer></script>
{% endblock %} {% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625l" /> <link rel="stylesheet" href="/static/project.css?v=20260625m" />
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host --> <!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" /> <link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
<!-- 타임라인 vis-timeline — self-host --> <!-- 타임라인 vis-timeline — self-host -->
@@ -222,6 +222,13 @@
<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>
<label class="pj-time-toggle">
<input type="checkbox" id="pj-t-usetime" /> 시간 지정 (체크 안 하면 종일)
</label>
<div class="pj-form-row" id="pj-time-row" hidden>
<label>시작 시간<input type="time" id="pj-t-stime" /></label>
<label>마감 시간<input type="time" id="pj-t-dtime" /></label>
</div>
<!-- 첨부파일 (기존 업무 편집 시에만) --> <!-- 첨부파일 (기존 업무 편집 시에만) -->
<div class="pj-task-extra" id="pj-attach-block" hidden> <div class="pj-task-extra" id="pj-attach-block" hidden>
<div class="pj-extra-head"> <div class="pj-extra-head">
@@ -290,5 +297,5 @@
</script> </script>
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script> <script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625l" defer></script> <script src="/static/project.js?v=20260625m" defer></script>
{% endblock %} {% endblock %}
+3
View File
@@ -181,6 +181,9 @@
} }
.pj-form-row { display: flex; gap: 12px; } .pj-form-row { display: flex; gap: 12px; }
.pj-form-row label { flex: 1; } .pj-form-row label { flex: 1; }
.pj-time-toggle { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; color: #525860; margin-bottom: 12px; cursor: pointer; }
.pj-time-toggle input { width: auto; margin: 0; }
.pj-form-row input[type="time"] { 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-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; } .pj-modal-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.pj-modal-actions .pj-btn:last-child { } .pj-modal-actions .pj-btn:last-child { }
+22 -3
View File
@@ -231,12 +231,16 @@
.map(function (t) { .map(function (t) {
const s = t.start_date || t.due_date; const s = t.start_date || t.due_date;
const e = t.due_date || t.start_date; const e = t.due_date || t.start_date;
// 시간 지정 시 date+time, 아니면 종일(날짜만)
const startISO = s + (t.start_time ? "T" + t.start_time : "");
const endISO = e + (t.due_time ? "T" + t.due_time : "");
const isRange = (e !== s) || (!!t.start_time && !!t.due_time && t.start_time !== t.due_time);
return { return {
id: t.id, id: t.id,
content: t.title, content: t.title,
start: s, start: startISO,
end: e !== s ? e : undefined, end: isRange ? endISO : undefined,
type: e !== s ? "range" : "point", type: isRange ? "range" : "point",
style: "background-color:" + (t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + ";color:#fff;border:none;", style: "background-color:" + (t.completed_at ? "#9aa5b1" : (t.project_color || "#4573d2")) + ";color:#fff;border:none;",
}; };
}); });
@@ -447,6 +451,12 @@
el("pj-t-priority").value = task ? (task.priority || "normal") : "normal"; el("pj-t-priority").value = task ? (task.priority || "normal") : "normal";
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 || "") : "";
// 시간 — 값이 있으면 '시간 지정' 체크 + 입력란 표시
const useTime = !!(task && (task.start_time || task.due_time));
el("pj-t-usetime").checked = useTime;
el("pj-t-stime").value = task ? (task.start_time || "") : "";
el("pj-t-dtime").value = task ? (task.due_time || "") : "";
el("pj-time-row").hidden = !useTime;
el("pj-delete-task").hidden = !task; el("pj-delete-task").hidden = !task;
// 댓글·첨부는 기존 업무 편집 시에만 // 댓글·첨부는 기존 업무 편집 시에만
currentTaskId = task ? task.id : null; currentTaskId = task ? task.id : null;
@@ -586,10 +596,16 @@
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); });
// '시간 지정' 체크 → 시간 입력란 토글
el("pj-t-usetime").addEventListener("change", function () {
el("pj-time-row").hidden = !this.checked;
});
el("pj-save-task").addEventListener("click", async function () { el("pj-save-task").addEventListener("click", async function () {
const id = el("pj-t-id").value; const id = el("pj-t-id").value;
const assigneeSel = el("pj-t-assignee"); const assigneeSel = el("pj-t-assignee");
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : ""; const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
const useTime = el("pj-t-usetime").checked;
const payload = { const payload = {
title: el("pj-t-title").value.trim(), title: el("pj-t-title").value.trim(),
description: el("pj-t-desc").value.trim(), description: el("pj-t-desc").value.trim(),
@@ -599,6 +615,9 @@
priority: el("pj-t-priority").value, priority: el("pj-t-priority").value,
start_date: el("pj-t-start").value || null, start_date: el("pj-t-start").value || null,
due_date: el("pj-t-due").value || null, due_date: el("pj-t-due").value || null,
// 시간 미지정(체크 해제) = 종일 → null 전송
start_time: useTime ? (el("pj-t-stime").value || null) : null,
due_time: useTime ? (el("pj-t-dtime").value || null) : null,
}; };
if (!payload.title) { alert("제목을 입력하세요."); return; } if (!payload.title) { alert("제목을 입력하세요."); return; }
try { try {
+17
View File
@@ -0,0 +1,17 @@
-- =====================================================================
-- project_db 마이그레이션 003 — 업무 시작/마감 시간(TIME) 추가
-- =====================================================================
-- 멱등. NULL = 시간 미지정(종일). DROP 없음.
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d project_db \
-- < scripts/sql/project_db_003_task_times.sql
-- =====================================================================
\set ON_ERROR_STOP on
\connect project_db
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS start_time TIME;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS due_time TIME;
SELECT 'project_db 003 ready' AS status;