@@ -23,13 +23,7 @@
function el ( id ) { return document . getElementById ( id ) ; }
function openModal ( m ) { if ( m ) m . hidden = false ; }
// 실시간 새로고침 대기 중이던 변경사항 — 모든 모달이 닫힌 시점에 반영(입력 중 방해 방지).
let pjPendingReload = false ;
function anyModalOpen ( ) { return ! ! document . querySelector ( ".pj-modal:not([hidden])" ) ; }
function closeModal ( m ) {
if ( m ) m . hidden = true ;
if ( pjPendingReload && ! anyModalOpen ( ) ) location . reload ( ) ;
}
function closeModal ( m ) { if ( m ) m . hidden = true ; }
function wireModalClose ( modal ) {
if ( ! modal ) return ;
@@ -38,6 +32,88 @@
} ) ;
}
// ── 토스트 알림 — alert() 대신 쓰는 비차단 피드백 ──
let toastEl = null , toastTimer = null ;
function showToast ( msg , type ) {
if ( ! toastEl ) {
toastEl = document . createElement ( "div" ) ;
toastEl . className = "erp-toast" ;
toastEl . setAttribute ( "role" , "status" ) ;
toastEl . setAttribute ( "aria-live" , "polite" ) ;
document . body . appendChild ( toastEl ) ;
}
toastEl . textContent = msg ;
toastEl . dataset . type = type || "info" ;
toastEl . dataset . visible = "true" ;
clearTimeout ( toastTimer ) ;
toastTimer = setTimeout ( function ( ) { toastEl . dataset . visible = "false" ; } , 2400 ) ;
}
// ── 우클릭 컨텍스트 메뉴 — 하나만 열리는 공용 컴포넌트.
// openDayPopover(아래)의 위치잡기/외부클릭 닫기 방식을 그대로 따르되, 앵커
// 엘리먼트 대신 클릭 좌표(clientX/Y) 기준으로 뷰포트에 클램프한다.
let ctxMenuEl = null ;
function closeContextMenu ( ) {
if ( ! ctxMenuEl ) return ;
ctxMenuEl . remove ( ) ;
ctxMenuEl = null ;
document . removeEventListener ( "mousedown" , onCtxDocDown , true ) ;
document . removeEventListener ( "keydown" , onCtxKeyDown , true ) ;
window . removeEventListener ( "scroll" , closeContextMenu , true ) ;
window . removeEventListener ( "resize" , closeContextMenu , true ) ;
}
function onCtxDocDown ( e ) {
if ( ctxMenuEl && ! ctxMenuEl . contains ( e . target ) ) closeContextMenu ( ) ;
}
function onCtxKeyDown ( e ) {
if ( e . key === "Escape" ) closeContextMenu ( ) ;
}
// items: [{label, danger?, onClick} | null(구분선)]
function showContextMenu ( x , y , items ) {
closeContextMenu ( ) ;
const menu = document . createElement ( "div" ) ;
menu . className = "pj-ctxmenu" ;
items . forEach ( function ( item ) {
if ( ! item ) {
const sep = document . createElement ( "div" ) ;
sep . className = "pj-ctxmenu-sep" ;
menu . appendChild ( sep ) ;
return ;
}
const b = document . createElement ( "button" ) ;
b . type = "button" ;
b . className = "pj-ctxmenu-item" + ( item . danger ? " is-danger" : "" ) ;
b . textContent = item . label ;
b . addEventListener ( "click" , function ( ) { closeContextMenu ( ) ; item . onClick ( ) ; } ) ;
menu . appendChild ( b ) ;
} ) ;
document . body . appendChild ( menu ) ;
ctxMenuEl = menu ;
let left = window . scrollX + x ;
let top = window . scrollY + y ;
left = Math . min ( left , window . scrollX + document . documentElement . clientWidth - menu . offsetWidth - 8 ) ;
top = Math . min ( top , window . scrollY + document . documentElement . clientHeight - menu . offsetHeight - 8 ) ;
menu . style . left = Math . max ( 8 , left ) + "px" ;
menu . style . top = Math . max ( 8 , top ) + "px" ;
setTimeout ( function ( ) {
document . addEventListener ( "mousedown" , onCtxDocDown , true ) ;
document . addEventListener ( "keydown" , onCtxKeyDown , true ) ;
window . addEventListener ( "scroll" , closeContextMenu , true ) ;
window . addEventListener ( "resize" , closeContextMenu , true ) ;
} , 0 ) ;
}
// ── 날짜 표기 'YYYY-MM-DD' → 'mm/dd(요일)' — 서버 _fmt_date_kr 과 동일 규칙 ──
function fmtDateKr ( dstr ) {
if ( ! dstr ) return "" ;
const d = new Date ( String ( dstr ) . slice ( 0 , 10 ) + "T00:00:00" ) ;
if ( isNaN ( d . getTime ( ) ) ) return dstr ;
const mm = String ( d . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) ;
const dd = String ( d . getDate ( ) ) . padStart ( 2 , "0" ) ;
const wd = [ "월" , "화" , "수" , "목" , "금" , "토" , "일" ] [ ( d . getDay ( ) + 6 ) % 7 ] ;
return mm + "/" + dd + "(" + wd + ")" ;
}
// ── 색상 팔레트 렌더 ───────────────────────────────────────
function renderColorPalette ( container , onPick , initial ) {
const colors = window . PJ _COLORS || [ "#4573d2" ] ;
@@ -78,6 +154,288 @@
return ! ! cb && cb === me ;
}
// 나중에 정의되는(블록 스코프) 모달 오픈 함수를 우클릭 메뉴 등 바깥에서도
// 부를 수 있도록 참조만 미리 선언해둔다(정의되는 즉시 아래서 채워짐).
let openNewTaskModalRef = null ;
let openHomeTaskModalRef = null ;
let openCommentsModalRef = null ;
// ── 프로젝트 카드 수정/삭제 (카드 ✎ 버튼·우클릭 메뉴 공용) ──
const editModal = el ( "pj-modal-edit" ) ;
let getEditColor = null ;
function openCardEditModal ( card ) {
el ( "pj-e-id" ) . value = card . dataset . id ;
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 ) ;
}
async function deleteProjectCard ( card ) {
if ( ! canDelete ( card . dataset . creator ) ) { alert ( "삭제는 만든 사람만 할 수 있습니다." ) ; return ; }
if ( ! confirm ( "이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다." ) ) return ;
try {
await api ( "DELETE" , "/project/api/projects/" + card . dataset . id ) ;
card . remove ( ) ;
} catch ( e ) { showToast ( "삭제 실패: " + e . message , "error" ) ; }
}
// 프로젝트 수정 저장 결과(row)를 기존 카드 DOM에 그대로 반영 — 새로고침 없음.
function patchProjectCardDom ( card , p ) {
card . dataset . name = p . name || "" ;
card . dataset . desc = p . description || "" ;
card . dataset . start = p . start _date || "" ;
card . dataset . due = p . due _date || "" ;
card . dataset . color = p . color || "#4573d2" ;
card . dataset . status = p . status || "active" ;
card . style . setProperty ( "--pj-color" , p . color || "#4573d2" ) ;
card . classList . toggle ( "is-done" , p . status === "completed" ) ;
const nameEl = card . querySelector ( ".pj-project-name" ) ;
if ( nameEl ) {
if ( nameEl . firstChild && nameEl . firstChild . nodeType === Node . TEXT _NODE ) {
nameEl . firstChild . textContent = p . name || "" ;
} else {
nameEl . insertBefore ( document . createTextNode ( p . name || "" ) , nameEl . firstChild ) ;
}
let doneChip = nameEl . querySelector ( ".pj-chip" ) ;
if ( p . status === "completed" ) {
if ( ! doneChip ) {
doneChip = document . createElement ( "span" ) ;
doneChip . className = "pj-chip pj-chip-sm" ;
doneChip . textContent = "완료" ;
nameEl . appendChild ( doneChip ) ;
}
} else if ( doneChip ) doneChip . remove ( ) ;
}
let descEl = card . querySelector ( ".pj-project-desc" ) ;
if ( p . description ) {
if ( ! descEl ) {
descEl = document . createElement ( "div" ) ;
descEl . className = "pj-project-desc" ;
if ( nameEl ) nameEl . insertAdjacentElement ( "afterend" , descEl ) ;
}
descEl . textContent = p . description ;
} else if ( descEl ) descEl . remove ( ) ;
const meta = card . querySelector ( ".pj-project-meta" ) ;
if ( meta ) {
let dueChip = null ;
meta . querySelectorAll ( ".pj-chip" ) . forEach ( function ( c ) {
if ( c . textContent . indexOf ( "마감" ) === 0 ) dueChip = c ;
} ) ;
if ( p . due _date ) {
const label = "마감 " + fmtDateKr ( p . due _date ) ;
if ( dueChip ) dueChip . textContent = label ;
else {
dueChip = document . createElement ( "span" ) ;
dueChip . className = "pj-chip" ;
dueChip . textContent = label ;
meta . insertBefore ( dueChip , meta . firstChild ) ;
}
} else if ( dueChip ) dueChip . remove ( ) ;
}
// 진행중 ↔ 완료 그리드 이동. 완료 그리드가 화면에 아직 없으면(첫 완료
// 프로젝트) 다음 새로고침까지 원래 위치에 남는다 — 드문 경우라 허용.
const targetGrid = el ( p . status === "completed" ? "pj-project-grid-done" : "pj-project-grid" ) ;
if ( targetGrid && card . parentElement !== targetGrid ) targetGrid . appendChild ( card ) ;
}
// 새 프로젝트 카드 DOM — project_card(p) 매크로(index.html)를 그대로 미러링.
// 방금 만든 프로젝트라 멤버/업무는 아직 없다(멤버는 생성 직후 별도 배정,
// 업무는 카드의 "업무 추가"로 나중에). 만든 사람은 자동 owner 라 관리 가능.
function projectCardHtml ( p ) {
const color = p . color || "#4573d2" ;
const html =
'<div class="pj-project-card" style="--pj-color: ' + color + '"' +
' data-id="' + p . id + '" data-name="' + escapeHtml ( p . name || "" ) + '"' +
' data-desc="' + escapeHtml ( p . description || "" ) + '"' +
' data-start="' + ( p . start _date || "" ) + '" data-due="' + ( p . due _date || "" ) + '"' +
' data-color="' + color + '" data-status="active" data-creator="' + escapeHtml ( p . created _by || "" ) + '">' +
'<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">' + escapeHtml ( p . name || "" ) + "</div>" +
( p . description ? '<div class="pj-project-desc">' + escapeHtml ( p . description ) + "</div>" : "" ) +
'<div class="pj-project-meta">' +
( p . due _date ? '<span class="pj-chip">마감 ' + fmtDateKr ( p . due _date ) + "</span>" : "" ) +
"</div></div></a>" +
'<div class="pj-card-actions">' +
'<button type="button" class="pj-icon-btn pj-card-addtask" data-project-id="' + p . id + '" title="업무 추가">' +
'<span class="material-symbols-outlined">add_task</span></button>' +
'<button type="button" class="pj-icon-btn pj-card-edit" title="프로젝트 수정">' +
'<span class="material-symbols-outlined">edit</span></button></div>' +
'<div class="pj-project-tasks-empty">진행 중인 업무가 없습니다.</div></div>' ;
const wrap = document . createElement ( "div" ) ;
wrap . innerHTML = html ;
return wrap . firstElementChild ;
}
// 카드 하나에 수정/업무추가/우클릭 메뉴를 건다 — 최초 로드된 카드·새로
// 만든 카드 모두 이 함수 하나로 통일(initHome 맨 끝에서 전체 카드에 호출).
function wireProjectCard ( card ) {
const editBtn = card . querySelector ( ".pj-card-edit" ) ;
if ( editBtn ) editBtn . addEventListener ( "click" , function ( e ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
openCardEditModal ( card ) ;
} ) ;
const addBtn = card . querySelector ( ".pj-card-addtask" ) ;
if ( addBtn ) addBtn . addEventListener ( "click" , function ( e ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
if ( openNewTaskModalRef ) openNewTaskModalRef ( card . dataset . id , card . dataset . name ) ;
} ) ;
card . addEventListener ( "contextmenu" , function ( e ) {
if ( ! card . querySelector ( ".pj-card-edit" ) ) return ; // 관리 권한 없는 카드는 메뉴 없음
e . preventDefault ( ) ;
showContextMenu ( e . clientX , e . clientY , [
{ label : "열기" , onClick : function ( ) { location . href = "/project/p/" + card . dataset . id ; } } ,
{ label : "업무 추가" , onClick : function ( ) { if ( openNewTaskModalRef ) openNewTaskModalRef ( card . dataset . id , card . dataset . name ) ; } } ,
{ label : "수정" , onClick : function ( ) { openCardEditModal ( card ) ; } } ,
null ,
{ label : "삭제" , danger : true , onClick : function ( ) { deleteProjectCard ( card ) ; } } ,
] ) ;
} ) ;
}
// ── 홈 카드 안 업무 리스트 patch (서버 재조회 없이) ──
function homeTaskRowHtml ( t ) {
const assigneeDisplay = t . assignee _name || ( t . assignee _email ? idOf ( t . assignee _email ) : "" ) ;
const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
const overdue = ! ! ( t . due _date && ! t . completed _at && String ( t . due _date ) . slice ( 0 , 10 ) < today ) ;
return '<li class="pj-project-taskrow' + ( t . completed _at ? " is-done" : "" ) + '" data-task-id="' + t . id + '">' +
'<a href="/project/p/' + t . project _id + '?task=' + t . id + '" data-task-id="' + t . id + '" class="pj-task-popup-link">' +
'<span class="pj-project-task-lead">' +
( 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>"
: "" ) +
"</span>" +
'<span class="pj-project-task-title">' + escapeHtml ( t . title || "" ) + "</span>" +
( t . stage _name ? '<span class="pj-project-task-stage">' + escapeHtml ( t . stage _name ) + "</span>" : "" ) +
( assigneeDisplay
? '<span class="pj-project-task-assignee"><span class="material-symbols-outlined pj-gicon pj-gicon-sm">account_circle</span>' +
escapeHtml ( assigneeDisplay ) + "</span>"
: "" ) +
( t . due _date ? '<span class="pj-project-task-due' + ( overdue ? " is-overdue" : "" ) + '">' + fmtDateKr ( t . due _date ) + "</span>" : "" ) +
"</a></li>" ;
}
function updateHomeCardProgress ( projectId ) {
const card = document . querySelector ( '.pj-project-card[data-id="' + projectId + '"]' ) ;
if ( ! card ) return ;
const total = card . querySelectorAll ( ".pj-project-taskrow" ) . length ;
const done = card . querySelectorAll ( ".pj-project-taskrow.is-done" ) . length ;
const meta = card . querySelector ( ".pj-project-meta" ) ;
if ( meta ) {
let chip = null ;
meta . querySelectorAll ( ".pj-chip" ) . forEach ( function ( c ) { if ( c . textContent . indexOf ( "업무" ) === 0 ) chip = c ; } ) ;
if ( total ) {
const label = "업무 " + done + "/" + total ;
if ( chip ) chip . textContent = label ;
else { chip = document . createElement ( "span" ) ; chip . className = "pj-chip" ; chip . textContent = label ; meta . appendChild ( chip ) ; }
} else if ( chip ) chip . remove ( ) ;
}
const body = card . querySelector ( ".pj-project-body" ) ;
let bar = card . querySelector ( ".pj-project-progress-bar" ) ;
if ( total ) {
if ( ! bar && body ) {
const w = document . createElement ( "div" ) ;
w . className = "pj-project-progress" ;
w . innerHTML = '<div class="pj-project-progress-bar"></div>' ;
body . appendChild ( w ) ;
bar = w . firstElementChild ;
}
if ( bar ) bar . style . width = Math . round ( done * 100 / total ) + "%" ;
} else if ( bar ) {
const prog = bar . closest ( ".pj-project-progress" ) ;
if ( prog ) prog . remove ( ) ;
}
}
function upsertHomeTaskRow ( t ) {
const card = document . querySelector ( '.pj-project-card[data-id="' + t . project _id + '"]' ) ;
if ( ! card ) { syncMyTaskPanel ( t ) ; return ; }
homeTasks [ t . id ] = t ;
let list = card . querySelector ( ".pj-project-tasklist" ) ;
const emptyMsg = card . querySelector ( ".pj-project-tasks-empty" ) ;
if ( ! list ) {
list = document . createElement ( "ul" ) ;
list . className = "pj-project-tasklist" ;
if ( emptyMsg ) emptyMsg . replaceWith ( list ) ;
else { const body = card . querySelector ( ".pj-project-body" ) ; if ( body ) body . appendChild ( list ) ; }
}
const wrap = document . createElement ( "div" ) ;
wrap . innerHTML = homeTaskRowHtml ( t ) ;
const newLi = wrap . firstElementChild ;
newLi . querySelector ( ".pj-task-popup-link" ) . addEventListener ( "click" , function ( e ) {
e . preventDefault ( ) ;
if ( openHomeTaskModalRef ) openHomeTaskModalRef ( t ) ;
} ) ;
const badge = newLi . querySelector ( ".pj-cmt-badge" ) ;
if ( badge ) {
badge . addEventListener ( "click" , function ( e ) { e . preventDefault ( ) ; e . stopPropagation ( ) ; } ) ;
badge . addEventListener ( "dblclick" , function ( e ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
if ( openCommentsModalRef ) openCommentsModalRef ( t . id ) ;
} ) ;
}
// 서버가 처음 그린 <li>(macro)는 data-task-id 를 <li> 자체가 아니라
// 안쪽 .pj-task-popup-link 에만 붙인다 — 그 기준으로 찾아야 최초
// 페이지 로드 때부터 있던 행(아직 한 번도 patch 안 된 행)도 잡힌다.
const existingLink = list . querySelector ( '.pj-task-popup-link[data-task-id="' + t . id + '"]' ) ;
const existing = existingLink ? existingLink . closest ( "li" ) : null ;
if ( existing ) existing . replaceWith ( newLi ) ; else list . appendChild ( newLi ) ;
updateHomeCardProgress ( t . project _id ) ;
syncMyTaskPanel ( t ) ;
}
function removeHomeTaskRow ( id , projectId ) {
const link = document . querySelector ( '.pj-project-taskrow .pj-task-popup-link[data-task-id="' + id + '"]' ) ;
const li = link ? link . closest ( "li" ) : null ;
const pid = projectId || ( li && li . closest ( ".pj-project-card" ) && li . closest ( ".pj-project-card" ) . dataset . id ) ;
if ( li ) {
const list = li . closest ( ".pj-project-tasklist" ) ;
li . remove ( ) ;
if ( list && ! list . children . length ) {
const empty = document . createElement ( "div" ) ;
empty . className = "pj-project-tasks-empty" ;
empty . textContent = "진행 중인 업무가 없습니다." ;
list . replaceWith ( empty ) ;
}
}
delete homeTasks [ id ] ;
if ( pid ) updateHomeCardProgress ( pid ) ;
syncMyTaskPanel ( { id : id , project _id : pid } , true ) ;
}
// "내 업무" 패널(우측) 동기화 — 이 프로젝트에 내 업무 그룹이 이미 있을
// 때만 patch 한다(그룹 자체가 없던 경우는 드물어 다음 새로고침까지 생략).
function syncMyTaskPanel ( t , removed ) {
const group = document . querySelector ( '.pj-mytree-group[data-group-id="' + t . project _id + '"] .pj-mytree-tasks' ) ;
if ( ! group ) return ;
const isMine = ! removed && t . assignee _email && String ( t . assignee _email ) . toLowerCase ( ) === me ;
const existingLink = group . querySelector ( '.pj-task-popup-link[data-task-id="' + t . id + '"]' ) ;
const existingLi = existingLink ? existingLink . closest ( "li" ) : null ;
if ( ! isMine ) { if ( existingLi ) existingLi . remove ( ) ; return ; }
const dates = t . start _date || t . due _date
? '<span class="pj-mytree-dates">' +
( t . start _date && t . due _date ? fmtDateKr ( t . start _date ) + " ~ " + fmtDateKr ( t . due _date )
: t . due _date ? "~ " + fmtDateKr ( t . due _date ) : fmtDateKr ( t . start _date ) + " ~" ) +
"</span>"
: "" ;
const html = '<li class="pj-mytree-task' + ( t . completed _at ? " is-done" : "" ) + '" data-task-id="' + t . id + '">' +
'<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">' +
( 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>"
: "" ) +
"</span>" +
'<span class="pj-mytree-body"><span class="pj-mytree-title">' + escapeHtml ( t . title || "" ) + "</span>" + dates + "</span>" +
"</a></li>" ;
const wrap = document . createElement ( "div" ) ;
wrap . innerHTML = html ;
const newLi = wrap . firstElementChild ;
newLi . querySelector ( ".pj-task-popup-link" ) . addEventListener ( "click" , function ( e ) {
e . preventDefault ( ) ;
if ( openHomeTaskModalRef ) openHomeTaskModalRef ( t ) ;
} ) ;
if ( existingLi ) existingLi . replaceWith ( newLi ) ; else group . appendChild ( newLi ) ;
}
// ── 완료 프로젝트 섹션 접기/펼치기 (localStorage 기억) ──
// display 를 인라인 스타일로 직접 제어 — 외부 CSS 규칙과의 특이도/우선순위
// 문제를 원천 차단(어떤 스타일시트보다 인라인 style 이 항상 우선한다).
@@ -148,36 +506,25 @@
try { await api ( "POST" , "/project/api/projects/" + pid + "/members" , { user _email : email } ) ; }
catch ( _ ) { }
}
location . reload ( ) ;
} catch ( e ) { alert ( "생성 실패: " + e . message ) ; }
closeModal ( modal ) ;
if ( grid ) {
const card = projectCardHtml ( res . project ) ;
grid . insertBefore ( card , grid . firstChild ) ;
wireProjectCard ( card ) ;
}
} catch ( e ) { showToast ( "생성 실패: " + e . message , "error" ) ; }
} ) ;
}
// ── 프로젝트 수정 모달 (진행중/완료 두 그리드 모두 대상) ──
const editModal = el ( "pj-modal-edit" ) ;
if ( editModal && grid ) {
wireModalClose ( editModal ) ;
let getEditColor = null ;
document . querySelectorAll ( ".pj-project-card .pj-card-edit" ) . forEach ( function ( btn ) {
btn . addEventListener ( "click" , function ( e ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
const card = btn . closest ( ".pj-project-card" ) ;
el ( "pj-e-id" ) . value = card . dataset . id ;
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 ) ;
} ) ;
} ) ;
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 , {
const res = await api ( "PUT" , "/project/api/projects/" + id , {
name : name ,
description : el ( "pj-e-desc" ) . value . trim ( ) ,
start _date : el ( "pj-e-start" ) . value || null ,
@@ -185,16 +532,22 @@
color : getEditColor ? getEditColor ( ) : undefined ,
status : el ( "pj-e-done" ) . checked ? "completed" : "active" ,
} ) ;
location . reload ( ) ;
} catch ( e ) { alert ( "저장 실패: " + e . message ) ; }
const card = document . querySelector ( '.pj-project-card[data-id="' + id + '"]' ) ;
if ( card ) patchProjectCardDom ( card , res . project ) ;
closeModal ( editModal ) ;
} catch ( e ) { showToast ( "저장 실패: " + e . message , "error" ) ; }
} ) ;
el ( "pj-delete-project" ) . addEventListener ( "click" , async function ( ) {
const id = el ( "pj-e-id" ) . value ;
const card = document . querySelector ( '.pj-project-card[data-id="' + id + '"]' ) ;
if ( card && ! canDelete ( card . dataset . creator ) ) { alert ( "삭제는 만든 사람만 할 수 있습니다." ) ; return ; }
if ( ! confirm ( "이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다." ) ) return ;
try { await api ( "DELETE" , "/project/api/projects/" + id ) ; location . reload ( ) ; }
catch ( e ) { alert ( "삭제 실패: " + e . message ) ; }
try {
await api ( "DELETE" , "/project/api/projects/ " + id ) ;
if ( card ) card . remove ( ) ;
closeModal ( editModal ) ;
}
catch ( e ) { showToast ( "삭제 실패: " + e . message , "error" ) ; }
} ) ;
}
@@ -414,6 +767,7 @@
loadTaskExtras ( task ) ;
homeTaskSnapshot = homeTaskFormSnapshot ( ) ;
}
openHomeTaskModalRef = openHomeTaskModal ;
// 카드 이름 옆 "업무 추가" 버튼 — 같은 모달을 새 업무 작성용으로 초기화해 연다.
async function openHomeNewTaskModal ( projectId , projectName ) {
@@ -439,6 +793,7 @@
await Promise . all ( [ fillAssignee ( projectId , "" ) , fillStage ( projectId , null ) ] ) ;
homeTaskSnapshot = homeTaskFormSnapshot ( ) ;
}
openNewTaskModalRef = openHomeNewTaskModal ;
function setHomeCompleteBtn ( done ) {
const btn = el ( "pj-toggle-complete" ) ;
btn . textContent = done ? "↩ 완료 취소" : "✓ 완료로 표시" ;
@@ -450,16 +805,10 @@
if ( ! id ) return ;
const done = el ( "pj-toggle-complete" ) . dataset . completed === "1" ;
try {
await api ( "PUT" , "/project/api/tasks/" + id , { completed : ! done } ) ;
location . reload ( ) ;
} catch ( e ) { alert ( "처리 실패: " + e . message ) ; }
} ) ;
document . querySelectorAll ( ".pj-card-addtask" ) . forEach ( function ( btn ) {
btn . addEventListener ( "click" , function ( e ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
const card = btn . closest ( ".pj-project-card" ) ;
openHomeNewTaskModal ( btn . dataset . projectId , card ? card . dataset . name : "" ) ;
} ) ;
const res = await api ( "PUT" , "/project/api/tasks/" + id , { completed : ! done } ) ;
setHomeCompleteBtn ( ! ! res . task . completed _at ) ;
upsertHomeTaskRow ( res . task ) ;
} catch ( e ) { showToast ( "처리 실패: " + e . message , "error" ) ; }
} ) ;
el ( "pj-t-usetime" ) . addEventListener ( "change" , function ( ) {
@@ -492,21 +841,29 @@
} ;
if ( ! payload . title ) { alert ( "제목을 입력하세요." ) ; return ; }
try {
let res ;
if ( id ) {
await api ( "PUT" , "/project/api/tasks/" + id , payload ) ;
res = await api ( "PUT" , "/project/api/tasks/" + id , payload ) ;
} else {
if ( ! pid ) return ;
await api ( "POST" , "/project/api/projects/" + pid + "/tasks" , payload ) ;
res = await api ( "POST" , "/project/api/projects/" + pid + "/tasks" , payload ) ;
}
location . reload ( ) ;
} catch ( e ) { alert ( "저장 실패: " + e . message ) ; }
upsertHomeTaskRow ( res . task ) ;
homeTaskSnapshot = null ;
closeModal ( homeTaskModal ) ;
} catch ( e ) { showToast ( "저장 실패: " + e . message , "error" ) ; }
} ) ;
el ( "pj-delete-task" ) . addEventListener ( "click" , async function ( ) {
const id = el ( "pj-t-id" ) . value ;
if ( ! id ) return ;
const pid = el ( "pj-t-project-id" ) . value ;
if ( ! confirm ( "이 업무를 삭제할까요?" ) ) return ;
try { await api ( "DELETE" , "/project/api/tasks/" + id ) ; location . reload ( ) ; }
catch ( e ) { alert ( "삭제 실패: " + e . message ) ; }
try {
await api ( "DELETE" , "/project/api/tasks/ " + id ) ;
removeHomeTaskRow ( id , pid ) ;
closeModal ( homeTaskModal ) ;
}
catch ( e ) { showToast ( "삭제 실패: " + e . message , "error" ) ; }
} ) ;
document . querySelectorAll ( ".pj-task-popup-link" ) . forEach ( function ( a ) {
@@ -603,6 +960,7 @@
const n = await renderChat ( taskId ) ;
if ( n !== null ) { markSeen ( taskId , n ) ; syncBadge ( taskId , n ) ; }
}
openCommentsModalRef = openCommentsModal ;
document . querySelectorAll ( ".pj-cmt-badge" ) . forEach ( function ( b ) {
b . addEventListener ( "click" , function ( e ) { e . preventDefault ( ) ; e . stopPropagation ( ) ; } ) ;
@@ -628,6 +986,9 @@
if ( e . key === "Enter" ) { e . preventDefault ( ) ; el ( "pj-cm-send" ) . click ( ) ; }
} ) ;
}
// 최초 로드된 카드 + 이후 새로 만든 카드 모두 여기서 한 번에 배선.
document . querySelectorAll ( ".pj-project-card" ) . forEach ( wireProjectCard ) ;
}
// ════════════════════════════════════════════════════════════
@@ -661,10 +1022,10 @@
const currentProject = data . project || { } ;
let stages = data . stages || [ ] ;
let tasks = data . tasks || [ ] ;
const members = data . members || [ ] ;
let members = data . members || [ ] ;
const priorityLabels = data . priorityLabels || { } ;
// 전체 프로젝트 집계 데이터 — 4개 뷰는 전 프로젝트를 보여준다.
const projectsMeta = data . projects || [ ] ;
let projectsMeta = data . projects || [ ] ;
const stagesByProject = data . stagesByProject || { } ;
const boardStages = data . boardStages || [ ] ;
const avatars = data . avatars || { } ;
@@ -892,6 +1253,11 @@
// 드래그(포인터 기반). 안 움직이면 클릭=모달, +N 칩 클릭=그날 팝오버.
function wireCalendarDrag ( cal ) {
cal . querySelectorAll ( ".pj-bar[data-task-id]" ) . forEach ( function ( bar ) {
if ( canManage ) bar . addEventListener ( "contextmenu" , function ( e ) {
e . preventDefault ( ) ;
const t = tasks . find ( function ( x ) { return String ( x . id ) === String ( bar . dataset . taskId ) ; } ) ;
if ( t ) showContextMenu ( e . clientX , e . clientY , taskContextItems ( t ) ) ;
} ) ;
bar . addEventListener ( "mousedown" , function ( e ) {
if ( e . button !== 0 ) return ;
e . preventDefault ( ) ;
@@ -1118,6 +1484,13 @@
const t = tasks . find ( function ( x ) { return x . id === props . items [ 0 ] ; } ) ;
if ( t ) openTaskModal ( t ) ;
} ) ;
// 배경 블록("bg"+그룹id)이나 빈 공간 우클릭은 무시 — 업무 아이템일 때만 메뉴.
timeline . on ( "contextmenu" , function ( props ) {
if ( props . event && props . event . preventDefault ) props . event . preventDefault ( ) ;
if ( ! canManage || props . item == null || String ( props . item ) . indexOf ( "bg" ) === 0 ) return ;
const t = tasks . find ( function ( x ) { return x . id === props . item ; } ) ;
if ( t ) showContextMenu ( props . event . clientX , props . event . clientY , taskContextItems ( t ) ) ;
} ) ;
// 사용자가 직접 확대/이동했을 때만 저장(autofit 등 프로그램 변경은 무시).
timeline . on ( "rangechanged" , function ( p ) { if ( p . byUser ) saveTLWindow ( p . start , p . end ) ; } ) ;
}
@@ -1315,37 +1688,47 @@
}
function wireColumnControls ( col , s ) {
const renameBtn = col . querySelector ( ".pj-col-rename" ) ;
if ( renameBtn ) renameBtn . addEventListener ( "click" , async function ( e ) {
e . stopPropagation ( ) ;
async function renameStage ( ) {
const name = prompt ( "세션 이름" , s . name ) ;
if ( ! name || ! name . trim ( ) || name . trim ( ) === s . name ) return ;
try {
await api ( "PUT" , "/project/api/projects/" + projectId + "/stages/" + s . id , { name : name . trim ( ) } ) ;
await reloadStagesForCurrentProject ( ) ;
renderBoard ( ) ;
} catch ( err ) { alert ( "이름 변경 실패: " + err . message ) ; }
} ) ;
const doneBtn = col . querySelector ( ".pj-col-toggledone" ) ;
if ( doneBtn ) doneBtn . addEventListener ( "click" , async function ( e ) {
e . stopPropagation ( ) ;
} catch ( err ) { showToast ( "이름 변경 실패: " + err . message , "error" ) ; }
}
async function toggleDoneStage ( ) {
try {
await api ( "PUT" , "/project/api/projects/" + projectId + "/stages/" + s . id ,
{ is _done _stage : ! s . is _done _stage } ) ;
await reloadStagesForCurrentProject ( ) ;
renderBoard ( ) ;
} catch ( err ) { alert ( "변경 실패: " + err . message ) ; }
} ) ;
const delBtn = col . querySelector ( ".pj-col-del" ) ;
if ( delBtn ) delBtn . addEventListener ( "click" , async function ( e ) {
e . stopPropagation ( ) ;
} catch ( err ) { showToast ( "변경 실패: " + err . message , "error" ) ; }
}
async function deleteStage ( ) {
if ( ! canDelete ( s . created _by ) ) { alert ( DELETE _DENIED ) ; return ; }
if ( ! confirm ( "'" + s . name + "' 세션을 삭제할까요? 이 세션의 업무는 '미지정'이 됩니다." ) ) return ;
try {
await api ( "DELETE" , "/project/api/projects/" + projectId + "/stages/" + s . id ) ;
await reloadStagesForCurrentProject ( ) ;
renderBoard ( ) ;
} catch ( err ) { alert ( "삭제 실패: " + err . message ) ; }
} catch ( err ) { showToast ( "삭제 실패: " + err . message , "error" ) ; }
}
const renameBtn = col . querySelector ( ".pj-col-rename" ) ;
if ( renameBtn ) renameBtn . addEventListener ( "click" , function ( e ) { e . stopPropagation ( ) ; renameStage ( ) ; } ) ;
const doneBtn = col . querySelector ( ".pj-col-toggledone" ) ;
if ( doneBtn ) doneBtn . addEventListener ( "click" , function ( e ) { e . stopPropagation ( ) ; toggleDoneStage ( ) ; } ) ;
const delBtn = col . querySelector ( ".pj-col-del" ) ;
if ( delBtn ) delBtn . addEventListener ( "click" , function ( e ) { e . stopPropagation ( ) ; deleteStage ( ) ; } ) ;
const head = col . querySelector ( ".pj-col-head" ) ;
if ( head ) head . addEventListener ( "contextmenu" , function ( e ) {
e . preventDefault ( ) ;
showContextMenu ( e . clientX , e . clientY , [
{ label : "이름 변경" , onClick : renameStage } ,
{ label : s . is _done _stage ? "완료 세션 해제" : "완료 세션으로 지정" , onClick : toggleDoneStage } ,
null ,
{ label : "삭제" , danger : true , onClick : deleteStage } ,
] ) ;
} ) ;
}
@@ -1455,6 +1838,10 @@
card . classList . add ( "is-dragging" ) ;
} ) ;
card . addEventListener ( "dragend" , function ( ) { card . classList . remove ( "is-dragging" ) ; } ) ;
card . addEventListener ( "contextmenu" , function ( e ) {
e . preventDefault ( ) ;
showContextMenu ( e . clientX , e . clientY , taskContextItems ( t ) ) ;
} ) ;
}
return card ;
}
@@ -1547,7 +1934,14 @@
}
return "<td>" + inner + "</td>" ;
} ) . join ( "" ) ;
if ( canManage ) { tr . style . cursor = "pointer" ; tr . addEventListener ( "click" , function ( ) { openTaskModal ( t ) ; } ) ; }
if ( canManage ) {
tr . style . cursor = "pointer" ;
tr . addEventListener ( "click" , function ( ) { openTaskModal ( t ) ; } ) ;
tr . addEventListener ( "contextmenu" , function ( e ) {
e . preventDefault ( ) ;
showContextMenu ( e . clientX , e . clientY , taskContextItems ( t ) ) ;
} ) ;
}
return tr ;
}
@@ -1631,6 +2025,38 @@
if ( typeof renderTree === "function" ) renderTree ( ) ;
}
// 업무 우클릭 메뉴 공용 항목 — 보드 카드·리스트 행·달력 막대·타임라인
// 아이템 4곳이 전부 이 함수 하나를 쓴다(열기/완료토글/삭제, 각각 기존
// 버튼과 동일한 API 경로·권한 검사를 그대로 재사용).
function taskContextItems ( t ) {
return [
{ label : "열기" , onClick : function ( ) { openTaskModal ( t ) ; } } ,
{
label : t . completed _at ? "완료 취소" : "완료로 표시" ,
onClick : async function ( ) {
try {
const res = await api ( "PUT" , "/project/api/tasks/" + t . id , { completed : ! t . completed _at } ) ;
mergeTask ( res . task ) ;
refreshActiveView ( ) ;
} catch ( e ) { showToast ( "처리 실패: " + e . message , "error" ) ; }
} ,
} ,
null ,
{
label : "삭제" , danger : true ,
onClick : async function ( ) {
if ( ! canDelete ( t . created _by ) ) { alert ( DELETE _DENIED ) ; return ; }
if ( ! confirm ( "이 업무를 삭제할까요?" ) ) return ;
try {
await api ( "DELETE" , "/project/api/tasks/" + t . id ) ;
tasks = tasks . filter ( function ( x ) { return x . id !== t . id ; } ) ;
refreshActiveView ( ) ;
} catch ( e ) { showToast ( "삭제 실패: " + e . message , "error" ) ; }
} ,
} ,
] ;
}
// ── 업무 모달 ──
const taskModal = el ( "pj-modal-task" ) ;
let currentTaskId = null ;
@@ -1998,17 +2424,21 @@
'<span class="pj-tree-avatar" style="background:' + ( p . color || "#4573d2" ) + '">' + initial + "</span>" +
'<span class="pj-tree-name">' + escapeHtml ( p . name ) + "</span></a>" + acts + "</div></li>" ;
}
async function deleteTreeProject ( id , name , createdBy ) {
if ( ! canDelete ( createdBy ) ) { alert ( DELETE _DENIED ) ; return ; }
if ( ! confirm ( "'" + ( name || "" ) + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다." ) ) return ;
try {
await api ( "DELETE" , "/project/api/projects/" + id ) ;
if ( String ( id ) === String ( projectId ) ) { location . href = "/project/" ; return ; }
projectsMeta = projectsMeta . filter ( function ( x ) { return String ( x . id ) !== String ( id ) ; } ) ;
renderTree ( ) ;
} catch ( err ) { showToast ( "삭제 실패: " + err . message , "error" ) ; }
}
function wireTreeHandlers ( rootUl ) {
rootUl . querySelectorAll ( ".pj-tree-del" ) . forEach ( function ( b ) {
b . addEventListener ( "click" , async function ( e ) {
b . addEventListener ( "click" , function ( e ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
if ( ! canDelete ( b . dataset . creator ) ) { alert ( DELETE _DENIED ) ; return ; }
if ( ! confirm ( "'" + ( b . dataset . name || "" ) + "' 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다." ) ) return ;
try {
await api ( "DELETE" , "/project/api/projects/" + b . dataset . id ) ;
if ( String ( b . dataset . id ) === String ( projectId ) ) location . href = "/project/" ;
else location . reload ( ) ;
} catch ( err ) { alert ( "삭제 실패: " + err . message ) ; }
deleteTreeProject ( b . dataset . id , b . dataset . name , b . dataset . creator ) ;
} ) ;
} ) ;
rootUl . querySelectorAll ( ".pj-tree-edit" ) . forEach ( function ( b ) {
@@ -2017,6 +2447,24 @@
openProjectEditModal ( b . dataset ) ;
} ) ;
} ) ;
if ( isAdmin ) rootUl . querySelectorAll ( ".pj-tree-li" ) . forEach ( function ( li ) {
li . addEventListener ( "contextmenu" , function ( e ) {
e . preventDefault ( ) ;
const editBtn = li . querySelector ( ".pj-tree-edit" ) ;
const delBtn = li . querySelector ( ".pj-tree-del" ) ;
showContextMenu ( e . clientX , e . clientY , [
{ label : "열기" , onClick : function ( ) { location . href = "/project/p/" + li . dataset . id ; } } ,
{ label : "수정" , onClick : function ( ) { if ( editBtn ) openProjectEditModal ( editBtn . dataset ) ; } } ,
null ,
{
label : "삭제" , danger : true ,
onClick : function ( ) {
if ( delBtn ) deleteTreeProject ( delBtn . dataset . id , delBtn . dataset . name , delBtn . dataset . creator ) ;
} ,
} ,
] ) ;
} ) ;
} ) ;
}
function renderTree ( ) {
const rootUl = el ( "pj-tree-root" ) ;
@@ -2024,6 +2472,17 @@
rootUl . innerHTML = projectsMeta . map ( treeProjLi ) . join ( "" ) ;
wireTreeHandlers ( rootUl ) ;
}
function mergeProject ( p ) {
const i = projectsMeta . findIndex ( function ( x ) { return String ( x . id ) === String ( p . id ) ; } ) ;
if ( i >= 0 ) projectsMeta [ i ] = Object . assign ( { } , projectsMeta [ i ] , p ) ;
else projectsMeta . push ( p ) ;
renderTree ( ) ;
if ( String ( p . id ) === String ( projectId ) ) {
Object . assign ( currentProject , p ) ;
const titleEl = document . querySelector ( ".pj-project-title" ) ;
if ( titleEl ) titleEl . textContent = currentProject . name || "" ;
}
}
renderTree ( ) ;
// 새 최상위 프로젝트 (관리자)
@@ -2033,9 +2492,10 @@
if ( ! name || ! name . trim ( ) ) return ;
const due = prompt ( "마감일 (YYYY-MM-DD, 없으면 취소 없이 비워두고 확인)" ) || "" ;
try {
await api ( "POST" , "/project/api/projects" , { name : name . trim ( ) , due _date : due . trim ( ) || null } ) ;
location . reload ( ) ;
} catch ( e ) { alert ( "생성 실패: " + e . message ) ; }
const res = await api ( "POST" , "/project/api/projects" , { name : name . trim ( ) , due _date : due . trim ( ) || null } ) ;
stagesByProject [ res . project . id ] = [ ] ;
mergeProject ( res . project ) ;
} catch ( e ) { showToast ( "생성 실패: " + e . message , "error" ) ; }
} ) ;
// ── 좌측 프로젝트 목록 ✎ 버튼 — 프로젝트 수정 모달(홈 화면과 동일).
@@ -2060,7 +2520,7 @@
const name = el ( "pj-e-name" ) . value . trim ( ) ;
if ( ! name ) { alert ( "이름을 입력하세요." ) ; return ; }
try {
await api ( "PUT" , "/project/api/projects/" + id , {
const res = await api ( "PUT" , "/project/api/projects/" + id , {
name : name ,
description : el ( "pj-e-desc" ) . value . trim ( ) ,
start _date : el ( "pj-e-start" ) . value || null ,
@@ -2068,25 +2528,58 @@
color : getEditColor ? getEditColor ( ) : undefined ,
status : el ( "pj-e-done" ) . checked ? "completed" : "active" ,
} ) ;
location . reload ( ) ;
} catch ( e ) { alert ( "저장 실패: " + e . message ) ; }
mergeProject ( res . project ) ;
closeModal ( editModal ) ;
} catch ( e ) { showToast ( "저장 실패: " + e . message , "error" ) ; }
} ) ;
el ( "pj-delete-project" ) . addEventListener ( "click" , async function ( ) {
el ( "pj-delete-project" ) . addEventListener ( "click" , function ( ) {
const id = el ( "pj-e-id" ) . value ;
const p = projectsMeta . find ( function ( x ) { return String ( x . id ) === String ( id ) ; } ) ;
if ( p && ! canDelete ( p . created _by ) ) { alert ( DELETE _DENIED ) ; return ; }
if ( ! confirm ( "이 프로젝트를 삭제할까요? 안의 업무도 모두 삭제됩니다." ) ) return ;
try {
await api ( "DELETE" , "/project/api/projects/" + id ) ;
if ( String ( id ) === String ( projectId ) ) location . href = "/project/" ;
else location . reload ( ) ;
} catch ( e ) { alert ( "삭제 실패: " + e . message ) ; }
closeModal ( editModal ) ;
deleteTreeProject ( id , p && p . name , p && p . created _by ) ;
} ) ;
}
// ── 멤버 배정/제외 (관리자) ──
const memberModal = el ( "pj-modal-member" ) ;
wireModalClose ( memberModal ) ;
function memberRowHtml ( m ) {
const url = avatars [ String ( m . user _email ) . toLowerCase ( ) ] ;
return "<li data-email=\"" + escapeHtml ( m . user _email ) + "\">" +
( url
? '<img class="pj-avatar pj-gicon-sm" src="' + escapeHtml ( url ) + '" alt="" referrerpolicy="no-referrer" />'
: '<span class="material-symbols-outlined pj-gicon">account_circle</span>' ) +
'<span class="pj-member-name">' + escapeHtml ( idOf ( m . user _email ) ) + "</span>" +
( m . role === "manager" ? '<span class="pj-chip pj-chip-sm">관리</span>' : "" ) +
( isAdmin ? '<button type="button" class="pj-icon-btn pj-del-member" data-email="' +
escapeHtml ( m . user _email ) + '" title="제외">× </button>' : "" ) +
"</li>" ;
}
async function removeMember ( email ) {
if ( ! confirm ( "이 멤버를 프로젝트에서 제외할까요?" ) ) return ;
try {
await api ( "DELETE" , "/project/api/projects/" + projectId + "/members/" + encodeURIComponent ( email ) ) ;
members = members . filter ( function ( m ) { return m . user _email !== email ; } ) ;
renderMemberList ( ) ;
} catch ( e ) { showToast ( "제외 실패: " + e . message , "error" ) ; }
}
function renderMemberList ( ) {
const ul = el ( "pj-member-list" ) ;
if ( ! ul ) return ;
ul . innerHTML = members . length ? members . map ( memberRowHtml ) . join ( "" ) : "<li class='pj-empty-sm'>없음</li>" ;
ul . querySelectorAll ( ".pj-del-member" ) . forEach ( function ( b ) {
b . addEventListener ( "click" , function ( ) { removeMember ( b . dataset . email ) ; } ) ;
} ) ;
if ( isAdmin ) ul . querySelectorAll ( "li[data-email]" ) . forEach ( function ( li ) {
li . addEventListener ( "contextmenu" , function ( e ) {
e . preventDefault ( ) ;
showContextMenu ( e . clientX , e . clientY , [
{ label : "제외" , danger : true , onClick : function ( ) { removeMember ( li . dataset . email ) ; } } ,
] ) ;
} ) ;
} ) ;
}
renderMemberList ( ) ;
const addMemberBtn = el ( "pj-add-member" ) ;
if ( addMemberBtn ) addMemberBtn . addEventListener ( "click" , async function ( ) {
const sel = el ( "pj-m-user" ) ;
@@ -2114,20 +2607,13 @@
if ( ! sel . value ) { alert ( "직원을 선택하세요." ) ; return ; }
const name = sel . selectedOptions [ 0 ] ? ( sel . selectedOptions [ 0 ] . dataset . name || "" ) : "" ;
try {
await api ( "POST" , "/project/api/projects/" + projectId + "/members" , {
const res = await api ( "POST" , "/project/api/projects/" + projectId + "/members" , {
user _email : sel . value , user _name : name , role : el ( "pj-m-role" ) . value ,
} ) ;
location . reload ( ) ;
} catch ( e ) { alert ( "배정 실패: " + e . message ) ; }
} ) ;
document . querySelectorAll ( ".pj-del-member" ) . forEach ( function ( b ) {
b . addEventListener ( "click" , async function ( ) {
if ( ! confirm ( "이 멤버를 프로젝트에서 제외할까요?" ) ) return ;
try {
await api ( "DELETE" , "/project/api/projects/" + projectId + "/members/" + encodeURIComponent ( b . dataset . email ) ) ;
location . reload ( ) ;
} catch ( e ) { alert ( "제외 실패: " + e . message ) ; }
} ) ;
members . push ( res . member ) ;
renderMemberList ( ) ;
closeModal ( memberModal ) ;
} catch ( e ) { showToast ( "배정 실패: " + e . message , "error" ) ; }
} ) ;
// 최초 렌더 — 아사나처럼 프로젝트에 들어오면 보드(세션)가 기본 화면이다
@@ -2135,8 +2621,8 @@
renderView ( currentView ) ;
// 사이드 트리에서 업무 클릭(?task=ID)으로 들어오면 해당 업무 모달 자동 열기.
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 드래그 이동 후 location.reload()
// 시 이 파라미터가 남아 모달이 다시 뜨는 것을 방지 .
// 단, 처리 후 URL 에서 task 파라미터를 제거한다 — 같은 페이지에 머무는 동안
// 다시 열리지 않도록 .
const params = new URLSearchParams ( location . search ) ;
const taskParam = params . get ( "task" ) ;
if ( taskParam ) {
@@ -2146,6 +2632,34 @@
const t = tasks . find ( function ( x ) { return String ( x . id ) === String ( taskParam ) ; } ) ;
if ( t ) openTaskModal ( t ) ;
}
// 8초 주기 실시간 동기화(initLiveRefresh)가 전체 새로고침 대신 부를 함수 —
// 이 프로젝트의 업무·세션·멤버만 재조회해 로컬 상태에 patch(입력 중인
// 모달 폼은 건드리지 않는다). "전체 프로젝트" 스코프에 걸린 다른
// 프로젝트의 변경이나 세션 이름변경/재정렬/삭제·멤버 제외·댓글 수정/삭제는
// live-version 자체가 감지 못하는 기존 사각지대 — 다음 새로고침까지 반영 안 됨.
softSyncProjectRef = async function ( ) {
try {
const [ tRes , sRes , mRes ] = await Promise . all ( [
api ( "GET" , "/project/api/projects/" + projectId + "/tasks" ) ,
api ( "GET" , "/project/api/projects/" + projectId + "/stages" ) ,
api ( "GET" , "/project/api/projects/" + projectId + "/members" ) ,
] ) ;
// 서버 페이지 렌더 시엔 router.py 가 각 업무에 context_project_id(=지금
// 보는 프로젝트 id)를 붙여준다(멀티호밍 스코프 필터의 기준) — 이 API
// 응답엔 그 필드가 없으므로 직접 붙여야 visibleTasks() 필터에서
// 안 사라진다.
( tRes . tasks || [ ] ) . forEach ( function ( t ) {
t . context _project _id = projectId ;
mergeTask ( t ) ;
} ) ;
stages = sRes . stages || [ ] ;
stagesByProject [ projectId ] = stages ;
members = mRes . members || [ ] ;
renderMemberList ( ) ;
refreshActiveView ( ) ;
} catch ( _ ) { }
} ;
}
// ════════════════════════════════════════════════════════════
@@ -2639,25 +3153,43 @@
const readAll = document . getElementById ( "pj-readall" ) ;
if ( ! inbox && ! readAll ) return ;
if ( readAll ) readAll . addEventListener ( "click" , async function ( ) {
try { await fetch ( "/project/api/notifications/read-all" , { method : "POST" } ) ; location . reload ( ) ; }
catch ( e ) { alert ( "실패: " + e . message ) ; }
try {
await fetch ( "/project/api/notifications/read-all" , { method : "POST" } ) ;
if ( inbox ) inbox . querySelectorAll ( ".pj-inbox-item.is-unread" ) . forEach ( function ( li ) {
li . classList . remove ( "is-unread" ) ;
} ) ;
if ( inbox ) inbox . querySelectorAll ( ".pj-inbox-item" ) . forEach ( function ( li ) {
li . addEventListener ( "click" , async function ( e ) {
if ( e . target . closest ( "a" ) ) return ; // '이동' 링크는 통과
}
catch ( e ) { showToast ( "실패: " + e . message , "error" ) ; }
} ) ;
async function markRead ( li ) {
if ( ! li . classList . contains ( "is-unread" ) ) return ;
try {
await fetch ( "/project/api/notifications/" + li . dataset . id + "/read" , { method : "POST" } ) ;
li . classList . remove ( "is-unread" ) ;
} catch ( _ ) { }
}
if ( inbox ) inbox . querySelectorAll ( ".pj-inbox-item" ) . forEach ( function ( li ) {
li . addEventListener ( "click" , function ( e ) {
if ( e . target . closest ( "a" ) ) return ; // '이동' 링크는 통과
markRead ( li ) ;
} ) ;
li . addEventListener ( "contextmenu" , function ( e ) {
if ( ! li . classList . contains ( "is-unread" ) ) return ; // 이미 읽었으면 메뉴 없음
e . preventDefault ( ) ;
showContextMenu ( e . clientX , e . clientY , [
{ label : "읽음으로 표시" , onClick : function ( ) { markRead ( li ) ; } } ,
] ) ;
} ) ;
} ) ;
}
// ── 실시간 새로고침 ──
// 다른 사용자가 프로젝트/업무/댓글/멤버/단계를 추가·수정하면, 같은 화면을 열어둔
// 모든 사용자가 자동으로 새로고침되도록 주기적으로 서버 버전을 확인한다.
// 모달을 열어 입력 중이면 방해하지 않고, 모달이 모두 닫힐 때(closeModal) 반영한다.
// 모든 사용자에게 8초 주기로 반영한다. 전체 새로고침 대신 배경 목록만 조용히
// 다시 받아 patch 한다(입력 중인 모달 폼은 건드리지 않음) — initProject() 가
// 끝나면서 softSyncProjectRef 를 채워준다(홈 페이지는 채우지 않아 벨 배지만
// 갱신). initLiveRefresh 는 어느 페이지든(프로젝트/홈) 항상 이 하나로 처리.
let softSyncProjectRef = null ;
function initLiveRefresh ( ) {
if ( ! document . querySelector ( ".pj-wrap, .pj-project-view" ) ) return ;
let baseline = null ;
@@ -2669,8 +3201,9 @@
if ( baseline === null ) {
baseline = d . ts ;
} else if ( d . ts !== baseline ) {
if ( anyModalOpen ( ) ) { pjPendingReload = true ; }
else { location . reload ( ) ; return ; }
baseline = d . ts ;
initBell ( ) ;
if ( softSyncProjectRef ) softSyncProjectRef ( ) ;
}
}
} catch ( _ ) { }