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
|
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"):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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