feat(project): 홈 프로젝트 카드 수정 버튼 + 완료 프로젝트 비활성/숨기기
- 카드에 수정(연필) 버튼: 이름·설명·기간·색상·완료 토글 + 삭제 (관리자) - 프로젝트 상태에 'completed' 추가, update_project status 허용 - 완료 프로젝트: 흐리게 비활성 + 홈 목록 맨 끝으로 정렬 - '완료 N개 숨기기' 토글 버튼(localStorage 기억) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -319,6 +319,8 @@ async def index(request: Request) -> HTMLResponse:
|
|||||||
include_archived=False,
|
include_archived=False,
|
||||||
member_email=None if admin else user["email"],
|
member_email=None if admin else user["email"],
|
||||||
)
|
)
|
||||||
|
# 완료된 프로젝트는 비활성으로 맨 끝에 (홈 카드 정렬). 안정 정렬.
|
||||||
|
projects = sorted(projects, key=lambda p: 1 if p.get("status") == "completed" else 0)
|
||||||
tree = _build_tree(projects)
|
tree = _build_tree(projects)
|
||||||
my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
|
my_tasks = st.list_tasks(assignee_email=user["email"], limit=500)
|
||||||
# 내 업무를 프로젝트별로 묶어 트리로 표시
|
# 내 업무를 프로젝트별로 묶어 트리로 표시
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ PRIORITIES: tuple[str, ...] = ("low", "normal", "high")
|
|||||||
PRIORITY_LABELS: dict[str, str] = {"low": "낮음", "normal": "보통", "high": "높음"}
|
PRIORITY_LABELS: dict[str, str] = {"low": "낮음", "normal": "보통", "high": "높음"}
|
||||||
|
|
||||||
# 프로젝트 상태
|
# 프로젝트 상태
|
||||||
PROJECT_STATUSES: tuple[str, ...] = ("active", "archived")
|
PROJECT_STATUSES: tuple[str, ...] = ("active", "completed", "archived")
|
||||||
|
|
||||||
# 멤버 역할
|
# 멤버 역할
|
||||||
MEMBER_ROLES: tuple[str, ...] = ("manager", "member")
|
MEMBER_ROLES: tuple[str, ...] = ("manager", "member")
|
||||||
|
|||||||
@@ -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=20260625q" />
|
<link rel="stylesheet" href="/static/project.css?v=20260625r" />
|
||||||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
<div class="pj-section-head">
|
<div class="pj-section-head">
|
||||||
<h2>프로젝트</h2>
|
<h2>프로젝트</h2>
|
||||||
<div class="pj-toolbar-right">
|
<div class="pj-toolbar-right">
|
||||||
|
{% set done_count = tree | selectattr('status', 'equalto', 'completed') | list | length %}
|
||||||
|
{% if done_count %}
|
||||||
|
<button type="button" class="pj-btn" id="pj-toggle-done" data-count="{{ done_count }}">
|
||||||
|
완료 {{ done_count }}개 숨기기
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<a class="pj-bell" href="/project/inbox" title="알림센터">
|
<a class="pj-bell" href="/project/inbox" title="알림센터">
|
||||||
<span class="material-symbols-outlined">notifications</span>
|
<span class="material-symbols-outlined">notifications</span>
|
||||||
<span class="pj-bell-badge" id="pj-bell-badge" hidden>0</span>
|
<span class="pj-bell-badge" id="pj-bell-badge" hidden>0</span>
|
||||||
@@ -34,19 +40,35 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="pj-project-grid">
|
<div class="pj-project-grid" id="pj-project-grid">
|
||||||
{% for p in tree %}
|
{% for p in tree %}
|
||||||
<a class="pj-project-card" href="/project/p/{{ p.id }}"
|
<div class="pj-project-card {% if p.status == 'completed' %}is-done{% endif %}"
|
||||||
style="--pj-color: {{ p.color }}">
|
style="--pj-color: {{ p.color }}"
|
||||||
<span class="pj-project-dot"></span>
|
data-id="{{ p.id }}"
|
||||||
<div class="pj-project-body">
|
data-name="{{ p.name }}"
|
||||||
<div class="pj-project-name">{{ p.name }}</div>
|
data-desc="{{ p.description or '' }}"
|
||||||
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
|
data-start="{{ p.start_date or '' }}"
|
||||||
<div class="pj-project-meta">
|
data-due="{{ p.due_date or '' }}"
|
||||||
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %}
|
data-color="{{ p.color }}"
|
||||||
|
data-status="{{ p.status or 'active' }}">
|
||||||
|
<a class="pj-project-cardlink" href="/project/p/{{ p.id }}">
|
||||||
|
<span class="pj-project-dot"></span>
|
||||||
|
<div class="pj-project-body">
|
||||||
|
<div class="pj-project-name">{{ p.name }}
|
||||||
|
{% if p.status == 'completed' %}<span class="pj-chip pj-chip-sm">완료</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
|
||||||
|
<div class="pj-project-meta">
|
||||||
|
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</a>
|
{% if is_admin %}
|
||||||
|
<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">
|
||||||
|
<span class="material-symbols-outlined">edit</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -111,10 +133,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 프로젝트 수정 모달 -->
|
||||||
|
<div class="pj-modal" id="pj-modal-edit" hidden>
|
||||||
|
<div class="pj-modal-card">
|
||||||
|
<h3>프로젝트 수정</h3>
|
||||||
|
<input type="hidden" id="pj-e-id" />
|
||||||
|
<label>이름<input type="text" id="pj-e-name" placeholder="프로젝트 이름" /></label>
|
||||||
|
<label>설명<textarea id="pj-e-desc" rows="2"></textarea></label>
|
||||||
|
<div class="pj-form-row">
|
||||||
|
<label>시작일<input type="date" id="pj-e-start" /></label>
|
||||||
|
<label>마감일<input type="date" id="pj-e-due" /></label>
|
||||||
|
</div>
|
||||||
|
<label>색상
|
||||||
|
<span class="pj-color-palette" id="pj-e-colors"></span>
|
||||||
|
</label>
|
||||||
|
<label class="pj-time-toggle">
|
||||||
|
<input type="checkbox" id="pj-e-done" /> 프로젝트 완료 (목록 끝으로 이동·비활성)
|
||||||
|
</label>
|
||||||
|
<div class="pj-modal-actions">
|
||||||
|
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-project">삭제</button>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button type="button" class="pj-btn" data-close>취소</button>
|
||||||
|
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-edit">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<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=20260625q" defer></script>
|
<script src="/static/project.js?v=20260625r" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+18
-2
@@ -35,10 +35,26 @@
|
|||||||
|
|
||||||
.pj-project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
.pj-project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
||||||
.pj-project-card {
|
.pj-project-card {
|
||||||
display: flex; gap: 12px; padding: 16px; border: 1px solid #e7e9ec; border-radius: 14px;
|
position: relative; border: 1px solid #e7e9ec; border-radius: 14px;
|
||||||
background: #fff; text-decoration: none; color: inherit; transition: box-shadow .14s, transform .1s;
|
background: #fff; transition: box-shadow .14s, transform .1s;
|
||||||
}
|
}
|
||||||
.pj-project-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); transform: translateY(-1px); }
|
.pj-project-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); transform: translateY(-1px); }
|
||||||
|
.pj-project-cardlink { display: flex; gap: 12px; padding: 16px; text-decoration: none; color: inherit; }
|
||||||
|
/* 완료 프로젝트 — 비활성(흐리게) */
|
||||||
|
.pj-project-card.is-done { background: #f6f7f8; opacity: .62; }
|
||||||
|
.pj-project-card.is-done:hover { opacity: .85; }
|
||||||
|
.pj-project-card.is-done .pj-project-dot { background: #9aa5b1; }
|
||||||
|
/* 완료 숨기기 토글 */
|
||||||
|
.pj-project-grid.pj-hide-done .pj-project-card.is-done { display: none; }
|
||||||
|
/* 카드 수정 버튼 */
|
||||||
|
.pj-card-edit {
|
||||||
|
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 8px; color: #6b7280; opacity: 0; transition: opacity .12s;
|
||||||
|
}
|
||||||
|
.pj-card-edit .material-symbols-outlined { font-size: 18px; }
|
||||||
|
.pj-project-card:hover .pj-card-edit { opacity: 1; }
|
||||||
|
.pj-card-edit:hover { background: #eceef0; color: #1e1f21; }
|
||||||
.pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; }
|
.pj-project-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pj-color, #4573d2); margin-top: 5px; flex: 0 0 auto; }
|
||||||
.pj-project-name { font-weight: 700; font-size: 15px; }
|
.pj-project-name { font-weight: 700; font-size: 15px; }
|
||||||
.pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; }
|
.pj-project-desc { color: #6b7280; font-size: 12.5px; margin-top: 3px; }
|
||||||
|
|||||||
+83
-19
@@ -33,9 +33,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 색상 팔레트 렌더 ───────────────────────────────────────
|
// ── 색상 팔레트 렌더 ───────────────────────────────────────
|
||||||
function renderColorPalette(container, onPick) {
|
function renderColorPalette(container, onPick, initial) {
|
||||||
const colors = window.PJ_COLORS || ["#4573d2"];
|
const colors = window.PJ_COLORS || ["#4573d2"];
|
||||||
let selected = colors[0];
|
let selected = (initial && colors.indexOf(initial) >= 0) ? initial : colors[0];
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
colors.forEach(function (c) {
|
colors.forEach(function (c) {
|
||||||
const b = document.createElement("button");
|
const b = document.createElement("button");
|
||||||
@@ -60,27 +60,91 @@
|
|||||||
// 홈 페이지 — 새 프로젝트
|
// 홈 페이지 — 새 프로젝트
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
function initHome() {
|
function initHome() {
|
||||||
|
const grid = el("pj-project-grid");
|
||||||
|
if (!grid && !el("pj-toggle-done")) return; // 홈 아님
|
||||||
|
|
||||||
|
// ── 완료 프로젝트 숨기기 토글 (localStorage 기억) ──
|
||||||
|
const toggleBtn = el("pj-toggle-done");
|
||||||
|
if (toggleBtn) {
|
||||||
|
const KEY = "pj_hide_done";
|
||||||
|
function apply(hide) {
|
||||||
|
if (grid) grid.classList.toggle("pj-hide-done", hide);
|
||||||
|
const n = toggleBtn.dataset.count;
|
||||||
|
toggleBtn.textContent = hide ? "완료 " + n + "개 보기" : "완료 " + n + "개 숨기기";
|
||||||
|
}
|
||||||
|
apply(localStorage.getItem(KEY) === "1");
|
||||||
|
toggleBtn.addEventListener("click", function () {
|
||||||
|
const hide = !(grid && grid.classList.contains("pj-hide-done"));
|
||||||
|
try { localStorage.setItem(KEY, hide ? "1" : "0"); } catch (_) {}
|
||||||
|
apply(hide);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 새 프로젝트 모달 ──
|
||||||
const newBtn = el("pj-new-project");
|
const newBtn = el("pj-new-project");
|
||||||
const modal = el("pj-modal-project");
|
const modal = el("pj-modal-project");
|
||||||
if (!newBtn || !modal) return;
|
if (newBtn && modal) {
|
||||||
wireModalClose(modal);
|
wireModalClose(modal);
|
||||||
const getColor = renderColorPalette(el("pj-f-colors"));
|
const getColor = renderColorPalette(el("pj-f-colors"));
|
||||||
|
newBtn.addEventListener("click", function () { openModal(modal); });
|
||||||
|
el("pj-save-project").addEventListener("click", async function () {
|
||||||
|
const name = el("pj-f-name").value.trim();
|
||||||
|
if (!name) { alert("이름을 입력하세요."); return; }
|
||||||
|
try {
|
||||||
|
await api("POST", "/project/api/projects", {
|
||||||
|
name: name,
|
||||||
|
description: el("pj-f-desc").value.trim(),
|
||||||
|
start_date: el("pj-f-start").value || null,
|
||||||
|
due_date: el("pj-f-due").value || null,
|
||||||
|
color: getColor(),
|
||||||
|
});
|
||||||
|
location.reload();
|
||||||
|
} catch (e) { alert("생성 실패: " + e.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
newBtn.addEventListener("click", function () { openModal(modal); });
|
// ── 프로젝트 수정 모달 ──
|
||||||
el("pj-save-project").addEventListener("click", async function () {
|
const editModal = el("pj-modal-edit");
|
||||||
const name = el("pj-f-name").value.trim();
|
if (editModal && grid) {
|
||||||
if (!name) { alert("이름을 입력하세요."); return; }
|
wireModalClose(editModal);
|
||||||
try {
|
let getEditColor = null;
|
||||||
await api("POST", "/project/api/projects", {
|
grid.querySelectorAll(".pj-card-edit").forEach(function (btn) {
|
||||||
name: name,
|
btn.addEventListener("click", function (e) {
|
||||||
description: el("pj-f-desc").value.trim(),
|
e.preventDefault(); e.stopPropagation();
|
||||||
start_date: el("pj-f-start").value || null,
|
const card = btn.closest(".pj-project-card");
|
||||||
due_date: el("pj-f-due").value || null,
|
el("pj-e-id").value = card.dataset.id;
|
||||||
color: getColor(),
|
el("pj-e-name").value = card.dataset.name || "";
|
||||||
|
el("pj-e-desc").value = card.dataset.desc || "";
|
||||||
|
el("pj-e-start").value = card.dataset.start || "";
|
||||||
|
el("pj-e-due").value = card.dataset.due || "";
|
||||||
|
el("pj-e-done").checked = card.dataset.status === "completed";
|
||||||
|
getEditColor = renderColorPalette(el("pj-e-colors"), null, card.dataset.color);
|
||||||
|
openModal(editModal);
|
||||||
});
|
});
|
||||||
location.reload();
|
});
|
||||||
} catch (e) { alert("생성 실패: " + e.message); }
|
el("pj-save-edit").addEventListener("click", async function () {
|
||||||
});
|
const id = el("pj-e-id").value;
|
||||||
|
const name = el("pj-e-name").value.trim();
|
||||||
|
if (!name) { alert("이름을 입력하세요."); return; }
|
||||||
|
try {
|
||||||
|
await api("PUT", "/project/api/projects/" + id, {
|
||||||
|
name: name,
|
||||||
|
description: el("pj-e-desc").value.trim(),
|
||||||
|
start_date: el("pj-e-start").value || null,
|
||||||
|
due_date: el("pj-e-due").value || null,
|
||||||
|
color: getEditColor ? getEditColor() : undefined,
|
||||||
|
status: el("pj-e-done").checked ? "completed" : "active",
|
||||||
|
});
|
||||||
|
location.reload();
|
||||||
|
} catch (e) { alert("저장 실패: " + e.message); }
|
||||||
|
});
|
||||||
|
el("pj-delete-project").addEventListener("click", async function () {
|
||||||
|
const id = el("pj-e-id").value;
|
||||||
|
if (!confirm("이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다.")) return;
|
||||||
|
try { await api("DELETE", "/project/api/projects/" + id); location.reload(); }
|
||||||
|
catch (e) { alert("삭제 실패: " + e.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user