// 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();