// app.jsx — root component, routes, layout const { Sidebar, Topbar, Dashboard, TrackDetail, Kanban, Editor, CertPackage, Intake, Catalog, Audit, Capa, Share, Chat, Fill, AssistantPanel } = window.RAlly; // 아직 구현되지 않은 화면을 정직하게 표시(무반응 대신 '준비 중') const Soon = ({ title, note, go }) => (
🚧
{title} · 준비 중
{note}
); const SCREENS = { intake: { crumbs: ['홈', '시작하기'], render: (go) => , fullBleed: false, }, chat: { crumbs: ['홈', 'AI 어시스턴트'], render: (go) => , fullBleed: false, }, templates: { crumbs: ['홈', '템플릿'], render: (go) => , fullBleed: false, }, risk: { crumbs: ['홈', '위험관리'], render: (go) => , fullBleed: false, }, audit: { crumbs: ['홈', '가상 심사'], render: (go) => , fullBleed: false, }, capa: { crumbs: ['홈', 'CAPA'], render: (go) => , fullBleed: false, }, share: { crumbs: ['홈', '외부 공유'], render: (go) => , fullBleed: false, }, dashboard: { crumbs: ['홈', '대시보드'], render: (go) => , fullBleed: false, }, track: { crumbs: ['홈', '트랙', '하트리듬 AI v2.1'], render: (go) => , fullBleed: false, }, kanban: { crumbs: ['홈', '트랙', '하트리듬 AI v2.1', 'STAGE 02'], render: (go) => , fullBleed: false, }, editor: { crumbs: ['홈', '문서', 'R-018'], render: (go) => , fullBleed: true, }, fill: { crumbs: ['홈', '정보 입력'], render: (go) => , fullBleed: false, }, cert: { crumbs: ['홈', '트랙', '하트리듬 AI v2.1', '인증 패키지'], render: (go) => , fullBleed: false, }, }; const SCREEN_LABELS = { intake: '00 온보딩 · 시작하기', dashboard: '01 D-01 메인 대시보드', track: '02 T-02 트랙 상세', kanban: '03 T-04 STAGE 02 칸반', editor: '04 DOC-03 문서 에디터', cert: '05 C-01 인증 패키지', templates: '06 템플릿 카탈로그', audit: '07 AI 가상 심사관', capa: '08 CAPA · 시정예방', share: '09 내보내기 · 공유', chat: '10 AI 어시스턴트', }; const App = () => { // 인증 게이트 — 신원의 근거는 서버 세션(GET /api/auth/me). localStorage는 첫 렌더용 캐시일 뿐. const [user, setUser] = React.useState(() => window.RAlly.getUser()); const [authReady, setAuthReady] = React.useState(false); // Initial screen from hash, default dashboard const initial = (typeof location !== 'undefined' && location.hash.replace('#', '')) || 'dashboard'; const [screen, setScreen] = React.useState(SCREENS[initial] ? initial : 'dashboard'); // 부팅 시 세션 확인 + 401 전역 처리(어떤 API든 401이면 로그인 화면으로) React.useEffect(() => { window.RAlly.fetchMe().then((u) => { setUser(u); setAuthReady(true); }); const onUnauth = () => setUser(null); window.addEventListener('rally:unauthorized', onUnauth); return () => window.removeEventListener('rally:unauthorized', onUnauth); }, []); const onLogin = (u) => { setUser(u); setScreen('dashboard'); try { history.replaceState(null, '', '#dashboard'); } catch (_) {} }; const logout = () => { window.RAlly.logout().then(() => setUser(null)); }; const go = (s) => { if (!SCREENS[s]) s = 'dashboard'; // 존재하지 않는 화면 방어(크래시 방지) if (s === screen) { window.scrollTo({ top: 0, behavior: 'instant' }); return; } setScreen(s); // pushState로 히스토리 항목을 남겨 브라우저/마우스 뒤로가기가 동작하게 함 try { history.pushState({ screen: s }, '', '#' + s); } catch (_) {} window.scrollTo({ top: 0, behavior: 'instant' }); }; // 뒤로/앞으로(브라우저 버튼·마우스 4/5번 버튼)로 화면 이동 동기화 React.useEffect(() => { const sync = () => { const s = location.hash.replace('#', ''); if (SCREENS[s]) setScreen(s); }; window.addEventListener('hashchange', sync); window.addEventListener('popstate', sync); return () => { window.removeEventListener('hashchange', sync); window.removeEventListener('popstate', sync); }; }, []); // 활성 트랙명을 가져와 crumbs의 '하트리듬 AI v2.1'을 실제 트랙명으로 치환 const [trackName, setTrackName] = React.useState(''); React.useEffect(() => { if (!user) return; // 미인증 상태에선 데이터 API를 부르지 않는다 const loadName = (tid) => fetch('/api/track/' + tid + '/detail').then((r) => r.json()) .then((d) => { if (d.track && d.track.name) setTrackName(d.track.name); }).catch(() => {}); const sel = window.__RALLY_TRACK; if (sel) { loadName(sel); return; } // 선택 트랙 없으면 실제 생성 트랙(profile 보유·최신) 해석 fetch('/api/tracks').then((r) => r.json()).then((d) => { const ts = d.tracks || []; const real = ts.filter((x) => x.profile); const pick = (real.length ? real : ts).slice(-1)[0]; if (pick) { window.__RALLY_TRACK = pick.id; loadName(pick.id); } }).catch(() => {}); }, [screen, user]); // 에디터 화면: 실제 문서번호를 가져와 크럼의 'R-018' 치환 const [docNo, setDocNo] = React.useState(''); React.useEffect(() => { if (screen !== 'editor') return; const did = window.__RALLY_DOC; if (!did) return; fetch('/api/doc/' + did + '/body').then((r) => r.json()) .then((d) => { if (d.ok && d.doc_no) setDocNo(d.doc_no); }).catch(() => {}); }, [screen]); // AI 어시스턴트 패널 — 모든 화면에 상주(우측). 접힘 상태는 localStorage 영속. const [panelOpen, setPanelOpen] = React.useState(() => window.RAlly.isPanelOpen()); const togglePanel = (b) => { setPanelOpen(b); window.RAlly.setPanelOpen(b); }; // 세션 확인 중(첫 렌더) — 캐시된 사용자도 없으면 잠깐 빈 화면 if (!authReady && !user) { return
; } // 로그인 안 됐으면 로그인 화면만 표시(데모 모드 토글 포함 앱 UI는 일절 노출되지 않음) if (!user) { return ; } const cfg = SCREENS[screen]; let crumbs = trackName ? cfg.crumbs.map((c) => c === '하트리듬 AI v2.1' ? trackName : c) : cfg.crumbs; if (screen === 'editor' && docNo) crumbs = crumbs.map((c) => c === 'R-018' ? docNo : c); return (
{window.RAlly.isDemo() && (
● 데모 모드 예시(더미) 데이터로 화면을 채워 보여주는 중입니다. 실제 서비스 화면은 왼쪽 데모 모드를 꺼서 확인하세요.
)} {cfg.render(go)}
); }; // Floating screen index — bottom right, lets reviewer jump between the 5 screens const ScreenSwitcher = ({ screen, go, panelOpen }) => { const [open, setOpen] = React.useState(false); return (
{open && (
P0 화면 · 클릭해서 이동
{Object.entries(SCREEN_LABELS).map(([k, label]) => (
{ go(k); setOpen(false); }} style={{ padding: '8px 10px', fontSize: 12.5, color: k === screen ? '#FFE6DA' : 'rgba(240,239,232,0.78)', background: k === screen ? 'rgba(217,96,62,0.18)' : 'transparent', borderRadius: 6, cursor: 'pointer', fontWeight: k === screen ? 600 : 500, }} >{label}
))}
)}
); }; ReactDOM.createRoot(document.getElementById('root')).render();