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:
@@ -13,7 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
@@ -331,12 +331,16 @@ class ProjectStore:
|
||||
priority: str = "normal",
|
||||
start_date: str | None = None,
|
||||
due_date: str | None = None,
|
||||
start_time: str | None = None,
|
||||
due_time: str | None = None,
|
||||
created_by: str = "",
|
||||
) -> dict[str, Any]:
|
||||
ttl = store.validate_task_title(title)
|
||||
pr = store.validate_priority(priority)
|
||||
sd = store.validate_date(start_date)
|
||||
dd = store.validate_date(due_date)
|
||||
sti = store.validate_time(start_time)
|
||||
dti = store.validate_time(due_time)
|
||||
assignee = store.norm_str(assignee_email, lower=True)
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
@@ -357,14 +361,15 @@ class ProjectStore:
|
||||
"""
|
||||
INSERT INTO tasks
|
||||
(project_id, stage_id, title, description, assignee_email,
|
||||
assignee_name, priority, start_date, due_date, sort_order, created_by)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
assignee_name, priority, start_date, due_date,
|
||||
start_time, due_time, sort_order, created_by)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
project_id, stage_id, ttl, store.norm_str(description),
|
||||
assignee, store.norm_str(assignee_name), pr, sd, dd,
|
||||
int(nxt["n"]), store.norm_str(created_by, lower=True),
|
||||
sti, dti, int(nxt["n"]), store.norm_str(created_by, lower=True),
|
||||
),
|
||||
).fetchone()
|
||||
if assignee:
|
||||
@@ -406,6 +411,12 @@ class ProjectStore:
|
||||
if "due_date" in fields:
|
||||
sets.append("due_date = %s")
|
||||
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:
|
||||
new_assignee = store.norm_str(fields["assignee_email"], lower=True)
|
||||
sets.append("assignee_email = %s")
|
||||
@@ -714,6 +725,8 @@ class ProjectStore:
|
||||
for k, v in list(out.items()):
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
elif isinstance(v, time):
|
||||
out[k] = v.strftime("%H:%M")
|
||||
elif isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
for k in ("id", "parent_id", "project_id", "stage_id", "task_id", "sort_order"):
|
||||
|
||||
@@ -681,6 +681,8 @@ async def api_create_task(
|
||||
priority=payload.get("priority", "normal"),
|
||||
start_date=payload.get("start_date"),
|
||||
due_date=payload.get("due_date"),
|
||||
start_time=payload.get("start_time"),
|
||||
due_time=payload.get("due_time"),
|
||||
created_by=user["email"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -108,6 +108,22 @@ def validate_date(d: Any) -> str | None:
|
||||
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:
|
||||
return f"[DBX 프로젝트] 업무 배정: {project_name} · {task_title} → {assignee}"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% 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" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script src="/static/project.js?v=20260625l" defer></script>
|
||||
<script src="/static/project.js?v=20260625m" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% 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" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -100,5 +100,5 @@
|
||||
<script>
|
||||
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
|
||||
</script>
|
||||
<script src="/static/project.js?v=20260625l" defer></script>
|
||||
<script src="/static/project.js?v=20260625m" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625l" />
|
||||
<link rel="stylesheet" href="/static/project.css?v=20260625m" />
|
||||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||
<!-- 타임라인 vis-timeline — self-host -->
|
||||
@@ -222,6 +222,13 @@
|
||||
<label>시작일<input type="date" id="pj-t-start" /></label>
|
||||
<label>마감일<input type="date" id="pj-t-due" /></label>
|
||||
</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-extra-head">
|
||||
@@ -290,5 +297,5 @@
|
||||
</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 %}
|
||||
|
||||
@@ -181,6 +181,9 @@
|
||||
}
|
||||
.pj-form-row { display: flex; gap: 12px; }
|
||||
.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 .pj-btn:last-child { }
|
||||
|
||||
|
||||
+22
-3
@@ -231,12 +231,16 @@
|
||||
.map(function (t) {
|
||||
const s = t.start_date || t.due_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 {
|
||||
id: t.id,
|
||||
content: t.title,
|
||||
start: s,
|
||||
end: e !== s ? e : undefined,
|
||||
type: e !== s ? "range" : "point",
|
||||
start: startISO,
|
||||
end: isRange ? endISO : undefined,
|
||||
type: isRange ? "range" : "point",
|
||||
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-start").value = task ? (task.start_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;
|
||||
// 댓글·첨부는 기존 업무 편집 시에만
|
||||
currentTaskId = task ? task.id : null;
|
||||
@@ -586,10 +596,16 @@
|
||||
const newTaskBtn = el("pj-new-task");
|
||||
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 () {
|
||||
const id = el("pj-t-id").value;
|
||||
const assigneeSel = el("pj-t-assignee");
|
||||
const assigneeName = assigneeSel.selectedOptions[0] ? (assigneeSel.selectedOptions[0].dataset.name || "") : "";
|
||||
const useTime = el("pj-t-usetime").checked;
|
||||
const payload = {
|
||||
title: el("pj-t-title").value.trim(),
|
||||
description: el("pj-t-desc").value.trim(),
|
||||
@@ -599,6 +615,9 @@
|
||||
priority: el("pj-t-priority").value,
|
||||
start_date: el("pj-t-start").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; }
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user