/* 검토·피드백 문서 탭 3개: (기획안|디자인) · 아티클 · 기타
- main/etc: 파일명 클릭 → 피그마식 주석 화면(변환 PDF or 이미지). 변환 실패 파일은 다운로드만.
- article : 파일명 클릭 → 즉시 다운로드. LGE 포함 전체 업로드 가능.
- main 탭에 미리보기 가능한 파일이 1개뿐이면 목록 생략하고 바로 주석 화면. */
/* 문서 뷰어 공용 — 작성자 역할별 대표색(LGE 빨강 / CNX·ADMIN 파랑) + 파일 크기 표기(최소 1KB, 0도 빈칸 안 됨). */
const annoColor = (role) => (role === 'LGE' ? ROLE_BADGE.LGE : ROLE_BADGE.CNX);
const fmtDocSize = (b) => (b > 1048576 ? (b / 1048576).toFixed(1) + 'MB' : Math.max(1, Math.round(b / 1024)) + 'KB');
/* 아티클 게시글 1개 — 접힘 행 + 펼침(원본 다운로드 · 댓글 스레드 · 작성창). 단일 열림은 부모가 제어. */
function ArticlePost({ doc, user, open, onToggle, onChanged }) {
const isStaff = user.role === 'CNX' || user.role === 'ADMIN';
const [comments, setComments] = useSE(null);
const [body, setBody] = useSE('');
const [files, setFiles] = useSE([]); // 첨부 예정 File 객체 배열
const [busy, setBusy] = useSE(false);
const reload = () => window.api.getDocComments(doc.id).then(setComments).catch(() => setComments([]));
useEE(() => { if (open) reload(); }, [open, doc.id]);
const pickFiles = (e) => { const picked = Array.from(e.target.files || []); e.target.value = ''; setFiles((prev) => [...prev, ...picked]); };
const rmFile = (i) => setFiles((prev) => prev.filter((_, k) => k !== i));
const canSend = (body.trim() || files.length > 0) && !busy;
const submit = async () => {
if (!canSend) return;
setBusy(true);
// POST 응답이 곧 새 댓글(shapeComment 형태)이라 목록 재조회 없이 로컬에 붙인다. onChanged는 접힌 글 뱃지(commentCount) 갱신용.
try { const c = await window.api.addDocComment(doc.id, body.trim(), files); setComments((prev) => [...(prev || []), c]); setBody(''); setFiles([]); onChanged && onChanged(); }
catch (err) { alert('등록 실패: ' + (err.message || err)); }
finally { setBusy(false); }
};
const delComment = async (c) => {
if (!confirm('이 댓글을 삭제할까요?')) return;
try { await window.api.deleteDocComment(c.id); setComments((prev) => (prev || []).filter((x) => x.id !== c.id)); onChanged && onChanged(); }
catch (err) { alert('삭제 실패: ' + (err.message || err)); }
};
// 글(아티클 파일) 삭제 — 업로더 본인 or 스태프. onChanged로 부모 목록 재조회 → 이 글이 목록에서 사라짐.
const delPost = async () => {
if (!confirm(`'${doc.originalName}' 글을 삭제할까요? (댓글·첨부도 함께 접근 불가가 됩니다)`)) return;
try { await window.api.deleteActionDoc(doc.id); onChanged && onChanged(); }
catch (err) { alert('삭제 실패: ' + (err.message || err)); }
};
const canDel = (author) => (author && author.id === user.id) || isStaff;
const count = open && comments ? comments.length : (doc.commentCount || 0);
return (
{doc.originalName}
{doc.uploader.name} · {String(doc.createdAt).slice(0, 10)} · {fmtDocSize(doc.sizeBytes)}
{count > 0 && 댓글 {count}}
{open ? '▲' : '▼'}
{open && (
{canDel(doc.uploader) && }
{comments === null ?
불러오는 중…
: comments.length === 0 ?
아직 댓글이 없습니다.
:
{comments.map((c) => {
const rc = annoColor(c.author.role);
return (
{c.author.name}
{c.author.role}
{String(c.createdAt).slice(0, 10)}
{canDel(c.author) && }
{c.body &&
{c.body}
}
{c.files.length > 0 && (
{c.files.map((f) => (
))}
)}
);
})}
}
)}
);
}
// initialPage·initialAnnoId = 알림 딥링크(주석/답글) — 그 주석이 달린 PDF 페이지로 열고 해당 주석을 펼친다.
function DocReviewTabs({ action, user, initialTab, initialDocId, initialPage, initialAnnoId }) {
const isStaff = user.role === 'CNX' || user.role === 'ADMIN';
const mainLabel = (action.task || '').includes('디자인') ? '디자인' : '기획안';
// 운영상 기획안/디자인 한 종류만 쓰기로 해서 '아티클'·'기타' 탭은 숨긴다(2026-08-07).
// 서버 API·업로드 카테고리는 그대로라 배열에 되돌려 넣으면 즉시 복구된다.
const TABS = [['main', mainLabel]];
// 탭별 업로드 허용 확장자 — main(기획안/디자인)=PDF 전용, 아티클=제한 없음, 기타=pdf·ppt·excel·이미지. (서버 ALLOWED_MIME와 일치)
const ACCEPT = { main: '.pdf', article: undefined, etc: '.pdf,.ppt,.pptx,.xls,.xlsx,.jpg,.jpeg,.png' };
// 숨긴 탭으로는 못 들어가게 막는다 — 옛 알림 딥링크(docTab='article' 등)를 눌러도 탭 버튼은
// 없는데 그 탭 목록이 뜨는 어긋난 상태가 되지 않도록. TABS를 되돌리면 딥링크도 같이 살아난다.
const [tab, setTab] = useSE(TABS.some(([k]) => k === initialTab) ? initialTab : 'main');
const [docs, setDocs] = useSE(null); // 현재 탭 파일 목록
const [open, setOpen] = useSE(null); // 주석 화면으로 연 doc
const [busy, setBusy] = useSE(false);
const [openPostId, setOpenPostId] = useSE(null); // 아티클 탭: 펼친 게시글 id(단일 열림)
// 딥링크가 지금 '실제로 연' 문서 {docId, page, annoId}. 사용자가 손으로 목록/탭을 건드리면 즉시 비운다 —
// 이게 없으면(문서 id 비교만 하면) 목록으로 나갔다 같은 문서를 다시 열 때마다 계속 그 페이지로 튄다.
const [dlTarget, setDlTarget] = useSE(null);
const canUpload = isStaff; // 업로드는 CNX·담당자(스태프)만 — LGE에게는 버튼 자체를 숨긴다
const autoOpened = useRef(false); // 탭/항목 진입당 1회만 자동 열기 — 이후 업로드·삭제 reload가 목록을 다시 낚아채지 않게
const reload = () => window.api.getActionDocs(action.id, tab).then(setDocs).catch(() => setDocs([]));
useEE(() => { setDocs(null); setOpen(null); setDlTarget(null); autoOpened.current = false; reload(); }, [action.id, tab]);
const dlDocDone = React.useRef(false);
useEE(() => {
if (!initialDocId || dlDocDone.current || !docs) return;
const d = docs.find((x) => x.id === initialDocId);
if (!d) { dlDocDone.current = true; return; }
dlDocDone.current = true;
autoOpened.current = true; // 딥링크가 문서를 지정했으니 '최신 PDF 자동열기'를 억제(덮어쓰기 방지)
if (tab === 'article') { setOpenPostId(d.id); } // 아티클: 그 글 펼침
else if (d.hasPdf || d.isImage) { setOpen(d); setDlTarget({ docId: d.id, page: initialPage, annoId: initialAnnoId }); }
}, [docs]);
// main 탭: 진입 시 1회, 가장 최근 PDF를 자동으로 주석 화면에 띄운다(docs는 최신순 정렬 → 첫 PDF = 최신).
useEE(() => {
if (tab !== 'main' || !docs || open || autoOpened.current) return;
const latestPdf = docs.find((d) => d.hasPdf);
if (latestPdf) { autoOpened.current = true; setOpen(latestPdf); }
}, [docs]);
const onUpload = async (e) => {
const f = e.target.files && e.target.files[0]; if (!f) return;
setBusy(true);
try { await window.api.uploadActionDocFile(action.id, tab, f); reload(); }
catch (err) { alert('업로드 실패: ' + (err.message || err)); }
finally { setBusy(false); e.target.value = ''; }
};
const del = async (d) => {
if (!confirm(`'${d.originalName}' 파일을 삭제할까요? (주석도 함께 숨겨집니다)`)) return;
try { await window.api.deleteActionDoc(d.id); if (open && open.id === d.id) setOpen(null); reload(); }
catch (err) { alert('삭제 실패: ' + (err.message || err)); }
};
const clickDoc = (d) => {
setDlTarget(null); // 손으로 연 문서 — 딥링크의 페이지·주석을 더는 적용하지 않는다(1페이지부터)
if (d.hasPdf || d.isImage) setOpen(d);
else window.api.downloadActionDoc(d.id, d.originalName);
};
// 항목 전체(기획안·아티클·기타) → zip. 파일이 하나도 없으면 서버가 404 → 사용자에게 안내.
const downloadAll = async () => {
try { await window.api.downloadActionDocsZip(action.id, action.task); }
catch (e) { alert(e && e.message ? e.message : '다운로드할 파일이 없습니다.'); }
};
return (
{action.task}
{canUpload && }
{/* 문서 탭 — 앱 표준 세그먼트 컨트롤(현황/피드백 L2와 동일). 활성색은 앱 accent(초록 아님) */}
{TABS.map(([k, l]) => )}
{open ? (
{open.originalName}
{/* 딥링크 페이지·주석은 그 딥링크가 방금 연 문서에만 적용 — 손으로 연 문서는 언제나 1페이지부터 */}
) : !docs ?
불러오는 중…
: docs.length === 0 ?
등록된 파일이 없습니다.{canUpload ? ' 위 버튼으로 올려보세요.' : ''}
: tab === 'article' ? (
{docs.map((d) => (
setOpenPostId((id) => (id === d.id ? null : d.id))}
onChanged={reload} />
))}
) : (
{docs.map((d) => (
clickDoc(d)} style={{ cursor: 'pointer', fontSize: 13, fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.originalName}
{!d.hasPdf && !d.isImage && 미리보기 불가}
{d.uploader.name} · {String(d.createdAt).slice(0, 10)} · {fmtDocSize(d.sizeBytes)}
{(isStaff || d.uploader.id === user.id) && }
))}
)}
);
}
/* ---------------- Doc annotator (검토·피드백 액션의 PDF/이미지 리뷰, doc 단위) ---------------- */
if (window.pdfjsLib) window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'app/vendor/pdf.worker.min.js';
function DocAnnotator({ doc, user, initialPage, initialAnnoId }) {
const isStaff = user.role === 'CNX' || user.role === 'ADMIN';
// 영역 주석 대표색은 모듈 상단 annoColor(LGE 빨강 / CNX·ADMIN 파랑) 재사용. 전역 ROLE_BADGE(뱃지용)와 별개 규칙.
const [pdf, setPdf] = useSE(null); // pdfjs document
const [page, setPage] = useSE(1);
const [nPages, setNPages] = useSE(0);
const [imgUrl, setImgUrl] = useSE(null); // 이미지 모드 blob URL
const [renderTick, setRenderTick] = useSE(0); // 폭 변화 시 재렌더 트리거(선명도 유지)
const [zoom, setZoom] = useSE(1); // 확대 배율(1=폭맞춤). 확대 시 고해상도 재렌더 + 가로 스크롤
const canvasRef = useRef(null);
const wrapRef = useRef(null);
const scrollRef = useRef(null); // 문서 스크롤 컨테이너(확대 시 가로 스크롤 기준 폭)
const blobRef = useRef(null);
const dragRef = useRef(null); // 라이브 드래그 사각형(0~1) — 렌더 클로저 지연 회피
// 문서 로드 → pdfjs (hasPdf) 또는 이미지(isImage)
useEE(() => {
if (!doc.hasPdf || !window.pdfjsLib) { setPdf(null); return; }
let cancelled = false;
setPage(1); // 교체(짧은 새 문서 포함) 시 항상 1페이지부터 → page 클램프
window.api.fetchBlobUrl('/docs/' + doc.id + '/pdf').then((bu) => {
if (cancelled) { URL.revokeObjectURL(bu); return null; }
if (blobRef.current) URL.revokeObjectURL(blobRef.current); // 이전 blob 해제(누수 방지)
blobRef.current = bu;
return window.pdfjsLib.getDocument(bu).promise;
}).then((d) => { if (d && !cancelled) { setPdf(d); setNPages(d.numPages); } })
.catch(() => { if (!cancelled) { setPdf(null); setNPages(0); alert('문서를 불러오지 못했습니다.'); } });
return () => { cancelled = true; };
}, [doc]);
// 알림 딥링크: 주석이 달린 페이지로 점프. pdf 로드가 끝난 뒤(numPages 확정)에 클램프해서 적용한다.
// 로드 이펙트와 분리해 둔 이유 — initialPage 를 그 이펙트의 deps 에 넣으면 blob 을 다시 받고,
// 안 넣으면 '부모가 매번 remount 된다'는 app.jsx 의 key 규칙에 암묵적으로 기대게 된다. 여기선 둘 다 아니다.
useEE(() => {
if (!pdf || !initialPage) return;
setPage(Math.min(Math.max(1, initialPage), pdf.numPages));
}, [pdf, initialPage]);
useEE(() => {
if (!doc.isImage) { setImgUrl(null); return; }
let cancelled = false;
setPage(1); setNPages(1);
window.api.fetchBlobUrl('/docs/' + doc.id + '/file').then((bu) => {
if (cancelled) { URL.revokeObjectURL(bu); return; }
if (blobRef.current) URL.revokeObjectURL(blobRef.current);
blobRef.current = bu;
setImgUrl(bu);
}).catch(() => { if (!cancelled) alert('이미지를 불러오지 못했습니다.'); });
return () => { cancelled = true; };
}, [doc]);
// 페이지 렌더 (PDF 모드만) — 표시 폭 × DPR × 오버샘플 만큼 고해상도로 그려 뷰어급 선명도.
useEE(() => {
if (!pdf || !canvasRef.current || !wrapRef.current || !scrollRef.current) return;
let task = null; let cancelled = false;
pdf.getPage(page).then((pg) => {
if (cancelled) return;
const cv = canvasRef.current; const wrap = wrapRef.current; const scroll = scrollRef.current;
if (!cv || !wrap || !scroll) return;
const base = pg.getViewport({ scale: 1 }); // PDF 원본 좌표계
const metrics = window.LGDocRender.computePdfRenderMetrics({
baseWidth: base.width,
baseHeight: base.height,
availableWidth: scroll.clientWidth || base.width,
zoom,
dpr: window.devicePixelRatio || 1,
});
const vp = pg.getViewport({ scale: metrics.viewportScale });
wrap.style.width = metrics.cssWidth + 'px'; // 확대 시 컨테이너 넘어 → 가로 스크롤
cv.width = metrics.canvasWidth; cv.height = metrics.canvasHeight;
cv.style.width = metrics.cssWidth + 'px';
cv.style.height = metrics.cssHeight + 'px';
window.__lgaiLastPdfRender = { docId: doc.id, page, dpr: window.devicePixelRatio || 1, ...metrics };
task = pg.render({ canvasContext: cv.getContext('2d'), viewport: vp, transform: metrics.transform });
task.promise.catch(() => {}); // cancel 시 reject 무시
}).catch(() => {});
return () => { cancelled = true; if (task) task.cancel(); };
}, [pdf, page, renderTick, zoom]);
// 이미지 모드: 확대/폭 변화 시 wrap 표시 폭만 갱신(래스터라 재렌더 없음, CSS 스케일).
useEE(() => {
if (!doc.isImage || !wrapRef.current || !scrollRef.current) return;
const avail = scrollRef.current.clientWidth || 1;
wrapRef.current.style.width = Math.max(1, Math.round(avail * zoom)) + 'px';
}, [imgUrl, zoom, renderTick]);
// 컨테이너 폭 변화(창 크기·목록 접기) 감지 → 재렌더. 폭이 실제로 바뀔 때만(높이 변화 루프 방지).
useEE(() => {
const scroll = scrollRef.current;
if (!scroll || typeof ResizeObserver === 'undefined') return;
let lastW = scroll.clientWidth; let t = null;
const ro = new ResizeObserver(() => {
const w = scroll.clientWidth;
if (w === lastW) return;
lastW = w;
clearTimeout(t); t = setTimeout(() => setRenderTick((n) => n + 1), 150);
});
ro.observe(scroll);
return () => { clearTimeout(t); ro.disconnect(); };
}, [pdf, doc.isImage]);
// Task 4: 주석 상태 + 로드
const [annos, setAnnos] = useSE([]);
const [sel, setSel] = useSE(null); // 선택=펼친 주석 id (PDF 박스 하이라이트 + 리스트에서 펼침). 아코디언식 단일 열림
const [drag, setDrag] = useSE(null); // {x0,y0,x1,y1} (0~1)
const [draftBody, setDraftBody] = useSE('');
const [reply, setReply] = useSE('');
const reloadAnnos = () => window.api.getAnnotations(doc.id).then(setAnnos).catch(() => setAnnos([]));
useEE(() => { reloadAnnos(); }, [doc.id]);
// 페이지가 바뀌면 그리기·입력 상태를 리셋한다. 단 sel(펼친 주석)은 '현재 페이지에 속하지 않을 때만' 지운다 —
// 무조건 null 로 지우면 아래 딥링크 이펙트가 편 주석을 이 이펙트가 덮어쓸 수 있어 선언 순서에 의존하게 된다.
useEE(() => {
dragRef.current = null; setDrag(null); setDraftBody(''); setReply('');
setSel((s) => (s && annos.some((a) => a.id === s && a.page === page) ? s : null));
}, [page]);
const toggleAnno = (id) => { setSel((s) => (s === id ? null : id)); setReply(''); };
const openAnno = (id) => { setSel(id); setReply(''); setTimeout(() => { const el = document.getElementById('anno-' + id); if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }, 0); };
const pageAnnos = annos.filter((a) => a.page === page);
// 알림 딥링크가 지목한 주석 펼치기(1회). 목표 페이지에 실제로 도달한 뒤에만 편다
// (PDF 로드 → setPage 는 비동기라 첫 커밋엔 아직 1페이지).
const dlAnnoDone = useRef(false);
useEE(() => {
if (!initialAnnoId || dlAnnoDone.current || !annos.length) return;
const a = annos.find((x) => x.id === initialAnnoId);
if (!a) { dlAnnoDone.current = true; return; } // 삭제된 주석 — 페이지 이동까지는 이미 됨
if (a.page !== page) return; // 아직 목표 페이지 전 → 다음 커밋에서 재시도
dlAnnoDone.current = true;
openAnno(a.id);
}, [annos, page]);
const canDel = (author) => (author && author.id === user.id) || isStaff;
// Task 4: 드래그로 영역 그리기
const rel = (e) => { const r = wrapRef.current.getBoundingClientRect(); const cl = (v) => Math.max(0, Math.min(1, v)); return { x: cl((e.clientX - r.left) / r.width), y: cl((e.clientY - r.top) / r.height) }; };
const onDown = (e) => {
if (e.target.dataset.box) return;
e.preventDefault();
const p = rel(e);
dragRef.current = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
setDrag({ ...dragRef.current }); setSel(null);
const move = (ev) => { if (!dragRef.current) return; const q = rel(ev); dragRef.current = { ...dragRef.current, x1: q.x, y1: q.y }; setDrag({ ...dragRef.current }); };
const up = () => {
window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up);
const d = dragRef.current; dragRef.current = null; if (!d) return;
const x = Math.min(d.x0, d.x1), y = Math.min(d.y0, d.y1), w = Math.abs(d.x1 - d.x0), h = Math.abs(d.y1 - d.y0);
if (w < 0.01 || h < 0.01) { setDrag(null); return; }
setDrag({ x0: x, y0: y, x1: x + w, y1: y + h, pending: true });
};
window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
};
const saveDraft = async () => {
if (!draftBody.trim()) return; const d = drag;
try { await window.api.createAnnotation(doc.id, { page, x: Math.min(d.x0, d.x1), y: Math.min(d.y0, d.y1), w: Math.abs(d.x1 - d.x0), h: Math.abs(d.y1 - d.y0), body: draftBody.trim(), kind: 'box' }); setDrag(null); setDraftBody(''); reloadAnnos(); }
catch (e) { alert('주석 저장 실패: ' + (e.message || e)); }
};
return (
{/* 컨트롤 바 — 좌: 페이지 이동, 우: 확대/축소 (크게, 세그먼트형) */}
{doc.hasPdf ? (
{page} / {nPages || '…'}
) :
}
{/* 주석 포함 PDF — 원본에 주석을 PDF 주석으로 심어 내려받는다(Acrobat 주석 패널에 뜸) */}
{/* 확대/축소 — 작은 글씨는 확대하면 고해상도로 다시 그려 선명해집니다 */}
{/* PDF 풀폭 */}
{doc.isImage
? (imgUrl &&

)
:
}
{pageAnnos.map((a, i) => {
const rc = annoColor(a.author.role); // 작성자 역할별 대표색(LGE 빨강 / CNX·ADMIN 파랑)
const hl = a.kind === 'highlight'; // 형광펜=반투명 마커, 네모=반투명+테두리
return (
{ e.stopPropagation(); openAnno(a.id); }}
style={{ position: 'absolute', left: `${a.x * 100}%`, top: `${a.y * 100}%`, width: `${a.w * 100}%`, height: `${a.h * 100}%`,
border: hl ? (sel === a.id ? `2px solid ${rc.c}` : 'none') : `${sel === a.id ? 3 : 2}px solid ${rc.c}`,
background: rc.c + (hl ? '4d' : '20'), opacity: sel && sel !== a.id ? 0.45 : 1, cursor: 'pointer' }}>
{i + 1}
);})}
{drag && (() => { const uc = annoColor(user.role); return
; })()}
{/* 주석 리스트업 — PDF 아래 풀폭 그리드 */}
이 페이지 주석 {pageAnnos.length}
· PDF 위 영역을 드래그해 새 주석을 남기세요
{drag && drag.pending && (
)}
{pageAnnos.length === 0 && !(drag && drag.pending)
?
아직 주석이 없습니다. 위 문서에서 영역을 드래그해 첫 주석을 남겨보세요.
: (
{pageAnnos.map((a, i) => {
const rc = annoColor(a.author.role);
const open = sel === a.id; // 아코디언 — 선택된 주석만 펼침
const replies = a.replies || [];
return (
{/* 접힘 행 — 항상 한 줄(본문 …말줄임). 클릭=펼침/접힘 토글 */}
toggleAnno(a.id)}>
{i + 1}
{a.author.name}
{a.author.role}
{!open && {a.body}}
{!open && replies.length > 0 && 답글 {replies.length}}
{open ? '▲' : '▼'}
{open && (
e.stopPropagation()}>
{a.body}
{replies.map((r) => { const rrc = annoColor(r.author.role); return (
{r.author.name} {r.author.role}
{canDel(r.author) &&
}
{r.body}
); })}
setReply(e.target.value)} placeholder="답글 남기기…" style={{ flex: 1, fontSize: 12.5, padding: '6px 9px', borderRadius: 8, border: '1px solid var(--line)' }} />
{canDel(a.author) && }
)}
);
})}
)}
);
}