feat(project): 아사나식 프로젝트 관리 모듈 추가

- project_db 신규(projects/members/stages/tasks/comments/activity, project_app CRUD)
- 프로젝트/서브프로젝트(self-FK), 업무·진행단계(칸반), 멤버 배정
- 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 토글 + 보드/리스트
- 진입은 로그인 회사 직원 전원, 생성/배정은 관리자 전용
- 업무 배정·완료 시 관리자 메일 알림(app/mail.py, SMTP_* env, 미설정 시 skip)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 11:22:05 +09:00
parent 8d7fe01c2d
commit c192d61c71
14 changed files with 2404 additions and 1 deletions
@@ -0,0 +1,98 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
{% endblock %}
{% block content %}
<div class="pj-wrap" data-is-admin="{{ 'true' if is_admin else 'false' }}">
<div class="pj-home">
<!-- 프로젝트 목록 -->
<section class="pj-home-main">
<div class="pj-section-head">
<h2>프로젝트</h2>
{% if is_admin %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-project">
+ 새 프로젝트
</button>
{% endif %}
</div>
{% if not tree %}
<div class="pj-empty">
아직 프로젝트가 없습니다.
{% if is_admin %}상단의 <b>새 프로젝트</b> 버튼으로 만들어 보세요.
{% else %}관리자가 프로젝트에 배정하면 여기에 표시됩니다.{% endif %}
</div>
{% endif %}
<div class="pj-project-grid">
{% for p in tree %}
<a class="pj-project-card" href="/project/p/{{ p.id }}"
style="--pj-color: {{ p.color }}">
<span class="pj-project-dot"></span>
<div class="pj-project-body">
<div class="pj-project-name">{{ p.name }}</div>
{% if p.description %}<div class="pj-project-desc">{{ p.description }}</div>{% endif %}
<div class="pj-project-meta">
{% if p.children %}<span class="pj-chip">서브 {{ p.children|length }}</span>{% endif %}
{% if p.due_date %}<span class="pj-chip">마감 {{ p.due_date }}</span>{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
</section>
<!-- 내 업무 -->
<aside class="pj-home-side">
<div class="pj-section-head"><h2>내 업무</h2></div>
{% if not my_tasks %}
<div class="pj-empty pj-empty-sm">배정된 업무가 없습니다.</div>
{% else %}
<ul class="pj-mytask-list">
{% for t in my_tasks %}
<li class="pj-mytask {% if t.completed_at %}is-done{% endif %}">
<a href="/project/p/{{ t.project_id }}">
<span class="pj-mytask-dot" style="background: {{ t.project_color }}"></span>
<span class="pj-mytask-title">{{ t.title }}</span>
<span class="pj-mytask-meta">
{{ t.project_name }}{% if t.due_date %} · {{ t.due_date }}{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</aside>
</div>
</div>
<!-- 새 프로젝트 모달 -->
{% if is_admin %}
<div class="pj-modal" id="pj-modal-project" hidden>
<div class="pj-modal-card">
<h3>새 프로젝트</h3>
<label>이름<input type="text" id="pj-f-name" placeholder="프로젝트 이름" /></label>
<label>설명<textarea id="pj-f-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>시작일<input type="date" id="pj-f-start" /></label>
<label>마감일<input type="date" id="pj-f-due" /></label>
</div>
<label>색상
<span class="pj-color-palette" id="pj-f-colors"></span>
</label>
<div class="pj-modal-actions">
<button type="button" class="pj-btn" data-close>취소</button>
<button type="button" class="pj-btn pj-btn-primary" id="pj-save-project">만들기</button>
</div>
</div>
</div>
{% endif %}
<script>
window.PJ_COLORS = {{ ["#4573d2","#37a3a3","#62a420","#e8a33d","#e8384f","#aa62e3","#f06a6a","#5a6772"] | tojson }};
</script>
<script src="/static/project.js?v=20260625a" defer></script>
{% endblock %}
@@ -0,0 +1,166 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/project.css?v=20260625a" />
<!-- FullCalendar (달력) · vis-timeline (타임라인) — 스켈레톤 단계는 CDN, 추후 self-host -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/styles/vis-timeline-graph2d.min.css" rel="stylesheet" />
{% endblock %}
{% block content %}
<div class="pj-wrap pj-project-view"
data-project-id="{{ project.id }}"
data-can-manage="{{ 'true' if can_manage else 'false' }}"
data-is-admin="{{ 'true' if is_admin else 'false' }}"
data-me="{{ user.email }}">
<div class="pj-layout">
<!-- ── 좌측: 서브프로젝트 + 멤버 ── -->
<aside class="pj-side">
<div class="pj-side-block">
<div class="pj-side-head">
<span>서브프로젝트</span>
{% if can_manage %}
<button type="button" class="pj-icon-btn" id="pj-new-sub" title="서브프로젝트 추가">+</button>
{% endif %}
</div>
<ul class="pj-sub-list" id="pj-sub-list">
{% for s in subprojects %}
<li>
<a href="/project/p/{{ s.id }}">
<span class="pj-sub-dot" style="background: {{ s.color }}"></span>{{ s.name }}
</a>
{% if can_manage %}
<button type="button" class="pj-icon-btn pj-del-sub" data-id="{{ s.id }}" title="삭제">×</button>
{% endif %}
</li>
{% else %}
<li class="pj-empty-sm">없음</li>
{% endfor %}
</ul>
</div>
<div class="pj-side-block">
<div class="pj-side-head">
<span>멤버</span>
{% if is_admin %}
<button type="button" class="pj-icon-btn" id="pj-add-member" title="멤버 배정">+</button>
{% endif %}
</div>
<ul class="pj-member-list" id="pj-member-list">
{% for m in members %}
<li>
<span class="pj-avatar">{{ (m.user_name or m.user_email)[0] | upper }}</span>
<span class="pj-member-name">{{ m.user_name or m.user_email }}</span>
{% if m.role == 'manager' %}<span class="pj-chip pj-chip-sm">관리</span>{% endif %}
{% if is_admin %}
<button type="button" class="pj-icon-btn pj-del-member" data-email="{{ m.user_email }}" title="제외">×</button>
{% endif %}
</li>
{% else %}
<li class="pj-empty-sm">없음</li>
{% endfor %}
</ul>
</div>
</aside>
<!-- ── 우측: 뷰 ── -->
<section class="pj-main">
<div class="pj-toolbar">
<div class="pj-view-tabs" id="pj-view-tabs">
<button type="button" class="pj-tab is-active" data-view="calendar">달력</button>
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
<button type="button" class="pj-tab" data-view="board">보드</button>
<button type="button" class="pj-tab" data-view="list">리스트</button>
</div>
{% if can_manage %}
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button>
{% endif %}
</div>
<!-- 달력 -->
<div class="pj-view" data-view="calendar">
<div id="pj-calendar"></div>
</div>
<!-- 타임라인 -->
<div class="pj-view" data-view="timeline" hidden>
<div id="pj-timeline"></div>
</div>
<!-- 보드(칸반) -->
<div class="pj-view" data-view="board" hidden>
<div class="pj-board" id="pj-board"></div>
</div>
<!-- 리스트 -->
<div class="pj-view" data-view="list" hidden>
<table class="pj-table" id="pj-list">
<thead>
<tr><th>업무</th><th>담당자</th><th>단계</th><th>우선순위</th><th>시작</th><th>마감</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
</div>
</div>
<!-- 업무 모달 -->
<div class="pj-modal" id="pj-modal-task" hidden>
<div class="pj-modal-card">
<h3 id="pj-task-modal-title">새 업무</h3>
<input type="hidden" id="pj-t-id" />
<label>제목<input type="text" id="pj-t-title" placeholder="업무 제목" /></label>
<label>설명<textarea id="pj-t-desc" rows="2"></textarea></label>
<div class="pj-form-row">
<label>담당자
<select id="pj-t-assignee">
<option value="">(미지정)</option>
{% for m in members %}
<option value="{{ m.user_email }}" data-name="{{ m.user_name or m.user_email }}">
{{ m.user_name or m.user_email }}
</option>
{% endfor %}
</select>
</label>
<label>단계
<select id="pj-t-stage">
{% for s in stages %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="pj-form-row">
<label>우선순위
<select id="pj-t-priority">
<option value="low">낮음</option>
<option value="normal" selected>보통</option>
<option value="high">높음</option>
</select>
</label>
<label>시작일<input type="date" id="pj-t-start" /></label>
<label>마감일<input type="date" id="pj-t-due" /></label>
</div>
<div class="pj-modal-actions">
<button type="button" class="pj-btn pj-btn-danger" id="pj-delete-task" hidden>삭제</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-task">저장</button>
</div>
</div>
</div>
<!-- 데이터 -->
<script id="pj-data" type="application/json">
{
"project": {{ project | tojson }},
"stages": {{ stages | tojson }},
"tasks": {{ tasks | tojson }},
"members": {{ members | tojson }},
"priorityLabels": {{ priority_labels | tojson }}
}
</script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.3/standalone/umd/vis-timeline-graph2d.min.js"></script>
<script src="/static/project.js?v=20260625a" defer></script>
{% endblock %}