59b7f904bf
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
443 lines
21 KiB
HTML
443 lines
21 KiB
HTML
{% extends "erp_base.html" %}
|
||
|
||
{% block head_extra %}
|
||
<link rel="stylesheet" href="/static/project.css?v=20260917e" />
|
||
<!-- 구글 머티리얼 심볼(담당자 아이콘 등) — self-host -->
|
||
<link rel="stylesheet" href="/static/vendor/material-symbols/material-symbols.css" />
|
||
<!-- 타임라인 vis-timeline — self-host -->
|
||
<link href="/static/vendor/vis-timeline/vis-timeline-graph2d.min.css" rel="stylesheet" />
|
||
{% endblock %}
|
||
|
||
{% block topbar_actions %}
|
||
<a class="pj-bell" href="/project/inbox" title="알림센터">
|
||
<span class="material-symbols-outlined">notifications</span>
|
||
<span class="pj-bell-badge" id="pj-bell-badge" hidden>0</span>
|
||
</a>
|
||
{% if can_manage %}
|
||
<button type="button" class="pj-btn pj-btn-primary" id="pj-new-task">+ 업무 추가</button>
|
||
{% endif %}
|
||
{% 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-is-super="{{ 'true' if user.is_super_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 is_admin %}
|
||
<button type="button" class="pj-icon-btn" id="pj-new-project-side" title="새 프로젝트">+</button>
|
||
{% endif %}
|
||
</div>
|
||
{% if nav_projects %}
|
||
<ul class="pj-tree" id="pj-tree-root">
|
||
{% for p in nav_projects %}
|
||
<li class="pj-tree-li" data-id="{{ p.id }}"
|
||
data-name="{{ p.name }}" data-desc="{{ p.description or '' }}"
|
||
data-start="{{ p.start_date or '' }}" data-due="{{ p.due_date or '' }}"
|
||
data-color="{{ p.color }}" data-status="{{ p.status or 'active' }}"
|
||
data-creator="{{ p.created_by or '' }}">
|
||
<div class="pj-tree-row {% if p.id == project.id %}is-active{% endif %}">
|
||
<a class="pj-tree-link" href="/project/p/{{ p.id }}">
|
||
<span class="pj-tree-avatar" style="background: {{ p.color }}">{{ p.name[0] | upper }}</span>
|
||
<span class="pj-tree-name">{{ p.name }}</span>
|
||
</a>
|
||
{% if is_admin %}
|
||
<span class="pj-tree-acts">
|
||
<button type="button" class="pj-icon-btn pj-tree-edit" title="프로젝트 수정">✎</button>
|
||
<button type="button" class="pj-icon-btn pj-tree-del" data-id="{{ p.id }}" data-name="{{ p.name }}" data-creator="{{ p.created_by }}" title="프로젝트 삭제">×</button>
|
||
</span>
|
||
{% endif %}
|
||
</div>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
{% else %}
|
||
<div class="pj-empty-sm">프로젝트 없음</div>
|
||
{% endif %}
|
||
</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>
|
||
{% if avatars.get(m.user_email | lower) %}
|
||
<img class="pj-avatar pj-gicon-sm" src="{{ avatars[m.user_email | lower] }}" alt="" referrerpolicy="no-referrer" />
|
||
{% else %}
|
||
<span class="material-symbols-outlined pj-gicon">account_circle</span>
|
||
{% endif %}
|
||
<span class="pj-member-name">{{ m.user_email.split('@')[0] }}</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="board">보드</button>
|
||
<button type="button" class="pj-tab" data-view="list">리스트</button>
|
||
<button type="button" class="pj-tab" data-view="calendar">달력</button>
|
||
<button type="button" class="pj-tab" data-view="timeline">타임라인</button>
|
||
</div>
|
||
<h1 class="pj-project-title">{{ project.name }}</h1>
|
||
<!-- 아사나처럼 기본은 "이 프로젝트"만 보여준다. 전체 프로젝트 보기는
|
||
예전처럼 접근 가능한 모든 프로젝트를 합쳐서 보여주는 모드. -->
|
||
<div class="pj-view-tabs" id="pj-scope-toggle">
|
||
<button type="button" class="pj-tab is-active" data-scope="project">이 프로젝트</button>
|
||
<button type="button" class="pj-tab" data-scope="all">전체 프로젝트</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 달력 (휴가 모듈과 동일한 월간 그리드) -->
|
||
<div class="pj-view" data-view="calendar" hidden>
|
||
<div class="pj-cal-head">
|
||
<a class="pj-btn pj-nav-btn" href="/project/p/{{ project.id }}?y={{ prev_y }}&m={{ prev_m }}">‹</a>
|
||
<h2 class="pj-cal-title">{{ year }}년 {{ month }}월</h2>
|
||
<a class="pj-btn pj-nav-btn" href="/project/p/{{ project.id }}?y={{ next_y }}&m={{ next_m }}">›</a>
|
||
<a class="pj-btn pj-today-btn" href="/project/p/{{ project.id }}">오늘</a>
|
||
</div>
|
||
|
||
<div class="pj-cal">
|
||
<div class="pj-wd-row">
|
||
{% for wd in weekdays %}
|
||
<div class="pj-wd {% if loop.index0 == 0 %}pj-red{% elif loop.index0 == 6 %}pj-blue{% endif %}">{{ wd }}</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
{% for week in weeks %}
|
||
<div class="pj-week">
|
||
<div class="pj-week-days">
|
||
{% for cell in week.days %}
|
||
<div class="pj-day {% if not cell.in_month %}pj-out{% endif %} {% if cell.is_today %}pj-today{% endif %}"
|
||
data-date="{{ cell.date }}" {% if cell.holiday_name %}title="{{ cell.holiday_name }}"{% endif %}>
|
||
<span class="pj-day-num
|
||
{% if cell.is_sunday or cell.holiday_name %}pj-red{% elif cell.is_saturday %}pj-blue{% endif %}">{{ cell.day }}</span>
|
||
{% if cell.holiday_name %}<span class="pj-day-holiday">{{ cell.holiday_name }}</span>{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- 막대는 클라이언트(JS)가 그린다 — 깔끔한 칩 + 행 제한(+N) + 드래그 이동 -->
|
||
<div class="pj-week-bars"></div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
<!-- 타임라인 -->
|
||
<div class="pj-view" data-view="timeline" hidden>
|
||
<div id="pj-timeline"></div>
|
||
</div>
|
||
<!-- 보드(칸반=세션) — 아사나처럼 프로젝트에 들어오면 기본으로 보이는 화면.
|
||
최대 4열 매이슨리로 배치하고, 세션이 늘어나면 pj-board 안에서만
|
||
세로 스크롤된다(좌측 프로젝트 목록·우측 내 업무와 스크롤이 서로
|
||
안 얽히게 각자 독립). "+ 세션 추가" 버튼은 스크롤 영역 밖(고정
|
||
위치)에 있어 항상 보인다. -->
|
||
<div class="pj-view" data-view="board">
|
||
<div class="pj-board-toolbar">
|
||
<button type="button" class="pj-add-stage" id="pj-board-addstage" hidden>+ 세션 추가</button>
|
||
</div>
|
||
<div class="pj-board" id="pj-board">
|
||
<div class="pj-board-canvas" id="pj-board-canvas"></div>
|
||
</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>
|
||
|
||
<!-- ── 우측: 내 업무 (홈 화면과 동일) ── -->
|
||
<aside class="pj-home-side pj-project-mytasks">
|
||
<div class="pj-section-head"><h2>내 업무</h2></div>
|
||
{% if not my_projects %}
|
||
<div class="pj-empty pj-empty-sm">배정된 업무가 없습니다.</div>
|
||
{% else %}
|
||
<div class="pj-mytree">
|
||
{% for p in my_projects %}
|
||
<div class="pj-mytree-group" data-group-id="{{ p.id }}">
|
||
<div class="pj-mytree-head">
|
||
<button type="button" class="pj-mytree-toggle" title="펼치기/접기">
|
||
<span class="material-symbols-outlined" style="color: {{ p.color }}; font-size:18px;">folder_open</span>
|
||
</button>
|
||
<a class="pj-mytree-proj" href="/project/p/{{ p.id }}">
|
||
<span class="pj-mytree-proj-name">{{ p.name }}</span>
|
||
</a>
|
||
</div>
|
||
<ul class="pj-mytree-tasks">
|
||
{% for t in p.tasks %}
|
||
<li class="pj-mytree-task {% if t.completed_at %}is-done{% endif %}">
|
||
<a href="/project/p/{{ t.project_id }}?task={{ t.id }}" data-task-id="{{ t.id }}" class="pj-task-popup-link">
|
||
<span class="pj-mytree-lead">
|
||
{% if t.comment_count %}
|
||
<span class="pj-cmt-badge" data-task-id="{{ t.id }}" data-count="{{ t.comment_count }}" title="댓글 {{ t.comment_count }}개">
|
||
<span class="pj-cmt-ic">💬</span><span class="pj-cmt-n">{{ t.comment_count }}</span>
|
||
</span>
|
||
{% endif %}
|
||
</span>
|
||
<span class="pj-mytree-body">
|
||
<span class="pj-mytree-title">{{ t.title }}</span>
|
||
{% if t.start_date or t.due_date %}
|
||
<span class="pj-mytree-dates">
|
||
{% if t.start_date and t.due_date %}{{ t.start_label }} ~ {{ t.due_label }}
|
||
{% elif t.due_date %}~ {{ t.due_label }}
|
||
{% else %}{{ t.start_label }} ~{% endif %}
|
||
</span>
|
||
{% endif %}
|
||
</span>
|
||
</a>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
</aside>
|
||
</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>
|
||
|
||
<!-- 업무 모달 -->
|
||
<div class="pj-modal" id="pj-modal-task" hidden>
|
||
<div class="pj-modal-card pj-task-modal-card">
|
||
<!-- 아사나처럼 큰 제목은 아래 무테 입력칸이 대신한다 — 이 표시는 작은
|
||
모드 안내(새 업무/업무 편집)만 한다. -->
|
||
<h3 id="pj-task-modal-title" class="pj-modal-kicker">새 업무</h3>
|
||
<input type="hidden" id="pj-t-id" />
|
||
<input type="text" id="pj-t-title" class="pj-t-title-input" placeholder="업무 제목" />
|
||
|
||
<div class="pj-modal-body">
|
||
<div class="pj-modal-col-main">
|
||
<!-- 메타 정보 — 담당자·세션·우선순위·일정을 작은 칩 형태로 한 줄에 -->
|
||
<div class="pj-meta-row">
|
||
<div class="pj-meta-field">
|
||
<span class="pj-meta-label">담당자</span>
|
||
<select id="pj-t-assignee">
|
||
<option value="">(미지정)</option>
|
||
{% for m in members %}
|
||
<option value="{{ m.user_email }}" data-name="{{ m.user_email.split('@')[0] }}">
|
||
{{ m.user_email.split('@')[0] }}
|
||
</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="pj-meta-field">
|
||
<span class="pj-meta-label">세션</span>
|
||
<select id="pj-t-stage">
|
||
{% for s in stages %}
|
||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="pj-meta-field">
|
||
<span class="pj-meta-label">우선순위</span>
|
||
<select id="pj-t-priority">
|
||
<option value="low">낮음</option>
|
||
<option value="normal" selected>보통</option>
|
||
<option value="high">높음</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="pj-meta-row">
|
||
<div class="pj-meta-field">
|
||
<span class="pj-meta-label">시작일</span>
|
||
<input type="date" id="pj-t-start" />
|
||
</div>
|
||
<div class="pj-meta-field">
|
||
<span class="pj-meta-label">마감일</span>
|
||
<input type="date" id="pj-t-due" />
|
||
</div>
|
||
</div>
|
||
<label class="pj-time-toggle">
|
||
<input type="checkbox" id="pj-t-usetime" /> 시간 지정 (체크 안 하면 종일)
|
||
</label>
|
||
<div class="pj-form-row" id="pj-time-row">
|
||
<label>시작 시간<input type="time" id="pj-t-stime" disabled /></label>
|
||
<label>마감 시간<input type="time" id="pj-t-dtime" disabled /></label>
|
||
</div>
|
||
|
||
<!-- 설명 — 간단한 워드프로세서식 서식 도구 + 이미지 삽입 -->
|
||
<div class="pj-desc-toolbar">
|
||
<span class="pj-extra-label">설명</span>
|
||
<span class="pj-muted-sm" id="pj-desc-hint"></span>
|
||
</div>
|
||
<div class="pj-desc-fmtbar" id="pj-desc-fmtbar">
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="bold" title="굵게 (Ctrl+B)"><b>B</b></button>
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="italic" title="기울임 (Ctrl+I)"><i>I</i></button>
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="underline" title="밑줄 (Ctrl+U)"><u>U</u></button>
|
||
<span class="pj-desc-fmtsep"></span>
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="insertUnorderedList" title="글머리 기호 목록">☰•</button>
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="insertOrderedList" title="번호 매기기 목록">☰1</button>
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="formatBlock" data-arg="blockquote" title="인용구">❝</button>
|
||
<span class="pj-desc-fmtsep"></span>
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="createLink" title="링크 삽입">🔗</button>
|
||
<button type="button" class="pj-desc-fmtbtn" data-cmd="removeFormat" title="서식 지우기">✕서식</button>
|
||
<span class="pj-desc-fmtsep"></span>
|
||
<label class="pj-desc-fmtbtn" id="pj-desc-imgbtn" title="이미지 추가"><span class="material-symbols-outlined">image</span><input type="file" id="pj-desc-img-input" accept="image/*" hidden /></label>
|
||
</div>
|
||
<div class="pj-desc-wrap">
|
||
<div class="pj-desc-editor" id="pj-t-desc" contenteditable="true" data-placeholder="설명을 입력하세요… (이미지는 붙여넣기 또는 위 버튼으로 추가)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pj-modal-col-side">
|
||
<!-- 하위 업무 (기존 업무 편집 시에만) -->
|
||
<div class="pj-task-extra" id="pj-subtask-block" hidden>
|
||
<div class="pj-extra-head">
|
||
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">checklist</span> 하위 업무
|
||
</div>
|
||
<ul class="pj-subtask-list" id="pj-subtask-list"></ul>
|
||
<div class="pj-subtask-form">
|
||
<input type="text" id="pj-subtask-input" placeholder="하위 업무 제목…" />
|
||
<select id="pj-subtask-add-assignee"><option value="">(미지정)</option></select>
|
||
<input type="date" id="pj-subtask-add-due" />
|
||
<button type="button" class="pj-btn pj-btn-primary" id="pj-subtask-add">추가</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 첨부파일 (기존 업무 편집 시에만) -->
|
||
<div class="pj-task-extra" id="pj-attach-block" hidden>
|
||
<div class="pj-extra-head">
|
||
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">attach_file</span> 첨부파일
|
||
<label class="pj-upload-btn">
|
||
파일 추가<input type="file" id="pj-attach-input" hidden />
|
||
</label>
|
||
</div>
|
||
<ul class="pj-attach-list" id="pj-attach-list"></ul>
|
||
</div>
|
||
|
||
<!-- 연결된 프로젝트 — 멀티호밍(아사나식). 기존 업무 편집 시에만 -->
|
||
<div class="pj-task-extra" id="pj-links-block" hidden>
|
||
<div class="pj-extra-head">
|
||
<span class="material-symbols-outlined pj-gicon pj-gicon-sm">link</span> 연결된 프로젝트
|
||
</div>
|
||
<div class="pj-link-chips" id="pj-link-chips"></div>
|
||
<div class="pj-link-form" id="pj-link-form">
|
||
<select id="pj-link-project"><option value="">프로젝트 선택…</option></select>
|
||
<select id="pj-link-stage"><option value="">세션 선택…</option></select>
|
||
<button type="button" class="pj-btn pj-btn-primary" id="pj-link-save">추가</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 댓글(활동) — 아사나처럼 맨 아래. 기존 업무 편집 시에만 -->
|
||
<div class="pj-task-extra" id="pj-comment-block" hidden>
|
||
<div class="pj-extra-head">
|
||
<span class="pj-cmt-emoji">💬</span> 댓글
|
||
</div>
|
||
<ul class="pj-comment-list" id="pj-comment-list"></ul>
|
||
<div class="pj-comment-form">
|
||
<input type="text" id="pj-comment-input" placeholder="댓글 입력…" />
|
||
<button type="button" class="pj-btn pj-btn-primary" id="pj-comment-send">등록</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="pj-modal-actions">
|
||
<button type="button" class="pj-btn pj-btn-complete" id="pj-toggle-complete" hidden>✓ 완료로 표시</button>
|
||
<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>
|
||
|
||
{% if is_admin %}
|
||
<!-- 멤버 배정 모달 — 등록된 사용자(프로젝트 권한 보유)를 자동 목록화 -->
|
||
<div class="pj-modal" id="pj-modal-member" hidden>
|
||
<div class="pj-modal-card">
|
||
<h3>멤버 배정</h3>
|
||
<p class="pj-modal-hint">관리자 페이지에서 <b>프로젝트 관리</b> 권한을 켠 직원이 후보로 나옵니다.</p>
|
||
<label>직원
|
||
<select id="pj-m-user"><option value="">불러오는 중…</option></select>
|
||
</label>
|
||
<label>역할
|
||
<select id="pj-m-role">
|
||
<option value="member" selected>멤버</option>
|
||
<option value="manager">관리(서브·업무 관리)</option>
|
||
</select>
|
||
</label>
|
||
<div class="pj-modal-actions">
|
||
<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-member">배정</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>window.PJ_COLORS = {{ color_palette | tojson }};</script>
|
||
<!-- 데이터 -->
|
||
<script id="pj-data" type="application/json">
|
||
{
|
||
"project": {{ project | tojson }},
|
||
"currentProjectId": {{ current_project_id | tojson }},
|
||
"stages": {{ stages | tojson }},
|
||
"tasks": {{ tasks | tojson }},
|
||
"members": {{ members | tojson }},
|
||
"priorityLabels": {{ priority_labels | tojson }},
|
||
"projects": {{ projects_meta | tojson }},
|
||
"stagesByProject": {{ stages_by_project | tojson }},
|
||
"boardStages": {{ board_stage_names | tojson }},
|
||
"avatars": {{ avatars | tojson }},
|
||
"myTasks": {{ my_tasks | tojson }}
|
||
}
|
||
</script>
|
||
|
||
<script src="/static/vendor/vis-timeline/vis-timeline-graph2d.min.js"></script>
|
||
<script src="/static/project.js?v=20260917d" defer></script>
|
||
{% endblock %}
|