// kanban.jsx — T-04 Stage 02 본격 주행 (칸반) const { Icon, Pill, Tag, Avatar, Button, Progress } = window.RAlly; const INITIAL_CARDS = [ // 시작 전 { id: 1, col: 'todo', cat: 'sop', title: 'SOP-PR-027 변경 관리 절차', owner: '박지', d: null }, { id: 2, col: 'todo', cat: 'tech', title: '설계 검증 보고서 §5 보안 평가', owner: '김민', d: null }, { id: 3, col: 'todo', cat: 'risk', title: 'R-022 외부 API 응답 지연 평가', owner: '이수', d: null }, { id: 4, col: 'todo', cat: 'sop', title: 'SOP-QA-021 부적합 처리', owner: '박지', d: 'D-21' }, { id: 5, col: 'todo', cat: 'capa', title: 'CAPA-2026-005 예방조치 계획', owner: '이수', d: 'D-18' }, // 작성 중 { id: 10, col: 'doing', cat: 'risk', title: 'R-018 인터페이스 오작동 통제 방안', owner: '박지', d: 'D-2', urgent: true, comments: 3 }, { id: 11, col: 'doing', cat: 'sop', title: 'SOP-QA-014 검증 절차서 v1.4', owner: '박지', d: 'D-3', urgent: true, comments: 5 }, { id: 12, col: 'doing', cat: 'tech', title: '설계 검증 보고서 §4.2 임상 데이터', owner: '김민', d: 'D-5', comments: 2, hasAi: true }, { id: 13, col: 'doing', cat: 'risk', title: 'R-021 잘못된 진단 결과 출력', owner: '박지', d: 'D-8', comments: 1 }, { id: 14, col: 'doing', cat: 'sop', title: 'SOP-PR-018 제품 출하 검사', owner: '이수', d: 'D-14', comments: 0 }, // 검토 중 { id: 20, col: 'review', cat: 'tech', title: '기술문서 §2 제품 설명서', owner: '김민', d: 'D-4', reviewer: '혜', reviewerCoral: true, comments: 4 }, { id: 21, col: 'review', cat: 'risk', title: '위험관리 보고서 v2.0', owner: '박지', d: 'D-6', reviewer: '혜', reviewerCoral: true, comments: 8 }, { id: 22, col: 'review', cat: 'sop', title: 'SOP-QA-009 내부 감사', owner: '이수', d: 'D-9', reviewer: '대표', comments: 1 }, { id: 23, col: 'review', cat: 'tech', title: '설계 입력·출력 매트릭스 v1.2', owner: '김민', d: 'D-11', reviewer: '박지', comments: 2 }, // 완주 { id: 30, col: 'done', cat: 'tech', title: '기술문서 §3 임상 평가', owner: '김민', completedAt: '오늘' }, { id: 31, col: 'done', cat: 'sop', title: 'SOP-DE-005 설계 입력 정의', owner: '박지', completedAt: '5/10' }, { id: 32, col: 'done', cat: 'risk', title: 'R-001~017 초기 위험 식별', owner: '박지', completedAt: '5/8' }, { id: 33, col: 'done', cat: 'capa', title: 'CAPA-2026-001 효과성 검증', owner: '이수', completedAt: '5/5' }, ]; const COLUMNS = [ { key: 'todo', icon: '📋', label: '시작 전', tone: 'default' }, { key: 'doing', icon: '🏃', label: '작성 중', tone: 'coral' }, { key: 'review', icon: '👀', label: '검토 중', tone: 'amber' }, { key: 'done', icon: '🏁', label: '완주', tone: 'green' }, ]; const CAT_META = { sop: { label: 'SOP', variant: 'default' }, risk: { label: '위험관리', variant: 'default' }, tech: { label: '기술문서', variant: 'default' }, capa: { label: 'CAPA', variant: 'default' }, }; const getTrackId = () => (typeof window !== 'undefined' && window.__RALLY_TRACK) || null; // 선택된 트랙이 없으면 실제 생성 트랙(가장 최근·문서 보유)을 기본으로 해석 const resolveTrackId = () => { const t = getTrackId(); if (t) return Promise.resolve(t); return fetch('/api/tracks').then((r) => r.json()).then((d) => { const tracks = d.tracks || []; // profile(실제 생성) 보유 트랙 우선, 없으면 마지막(최신) 트랙 const real = tracks.filter((x) => x.profile); const pick = (real.length ? real : tracks).slice(-1)[0]; const id = pick ? pick.id : 1; if (typeof window !== 'undefined') window.__RALLY_TRACK = id; return id; }).catch(() => 1); }; // 백엔드 카드 → 렌더 카드 매핑 const mapCard = (d) => ({ id: d.id, col: d.kanban_col, cat: d.category, title: d.name, owner: d.owner_initials || (d.author || '').slice(0, 2), d: d.deadline, reviewer: d.reviewer, reviewerCoral: d.reviewer === '혜', comments: d.comments, completedAt: d.kanban_col === 'done' ? '완주' : null, realgrade: !!d.realgrade, docType: d.doc_type || null, ownerRole: d.owner_role || null, ownerRoleLabel: d.owner_role_label || null, urgent: d.deadline && /^D-\d+$/.test(d.deadline) && parseInt(d.deadline.slice(2)) <= 3, }); const Kanban = ({ go }) => { const [cards, setCards] = React.useState([]); const [draggingId, setDraggingId] = React.useState(null); const [dropCol, setDropCol] = React.useState(null); const [activeFilters, setActiveFilters] = React.useState(new Set(['sop', 'risk', 'tech', 'capa'])); const [trackName, setTrackName] = React.useState(''); // 로그인 사용자 역할 — 개발자(AUTHOR)면 '내 문서만'(개발 문서) 토글 제공, 기본 ON const me = (window.RAlly.getUser && window.RAlly.getUser()) || null; const isAuthor = !!(me && me.role === 'AUTHOR'); const [mineOnly, setMineOnly] = React.useState(isAuthor); const loadBoard = React.useCallback(() => { resolveTrackId().then((tid) => fetch('/api/track/' + tid + '/kanban')) .then((r) => r.json()) .then((d) => { const all = []; (d.columns || []).forEach((col) => (d.board[col] || []).forEach((c) => all.push(mapCard(c)))); setCards(all); if (d.track && d.track.name) setTrackName(d.track.name); }) .catch(() => {}); }, []); React.useEffect(() => { loadBoard(); }, [loadBoard]); const toggleFilter = (cat) => { const next = new Set(activeFilters); if (next.has(cat)) next.delete(cat); else next.add(cat); setActiveFilters(next); }; const onDragStart = (e, id) => { setDraggingId(id); e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', String(id)); } catch (_) {} }; const onDragEnd = () => { setDraggingId(null); setDropCol(null); }; const onDragOverCol = (e, col) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dropCol !== col) setDropCol(col); }; const onDropCol = (e, col) => { e.preventDefault(); if (draggingId == null) return; const id = draggingId; const prevCol = (cards.find((c) => c.id === id) || {}).col; setCards((prev) => prev.map((c) => c.id === id ? { ...c, col, completedAt: col === 'done' ? '완주' : null } : c)); setDraggingId(null); setDropCol(null); // 백엔드 상태 전이 + 승인 게이트 — 행위자·역할은 서버 세션에서 도출(위조 방지) fetch('/api/kanban/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ doc_id: id, col }), }) .then((r) => r.json()) .then((d) => { if (!d.ok) { alert('이동 거부 — ' + (d.error || '권한')); loadBoard(); // 낙관적 이동 되돌림 } }) .catch(() => loadBoard()); }; const visible = cards.filter((c) => activeFilters.has(c.cat) && (!(isAuthor && mineOnly) || c.ownerRole === 'developer')); return (