// ui.jsx — shared primitives for RAlly // Icons inline as SVGs (lucide-style). Stroke 1.75, currentColor. const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => { const paths = ICONS[name]; if (!paths) return null; return ( {paths} ); }; const ICONS = { flag: <>, run: <>, trophy: <>, heart: <>, search: <>, bell: <>, plus: <>, check: <>, x: <>, alert: <>, alert_circle: <>, info: <>, sparkles: <>, file: <>, folder: <>, dots: <>, arrow_right: <>, chev_right: <>, chev_down: <>, chev_up: <>, download: <>, share: <>, eye: <>, edit: <>, copy: <>, trash: <>, link: <>, list: <>, grid: <>, bold: <>, italic: <>, underline: <>, h1: <>, h2: <>, table: <>, filter: <>, user: <>, users: <>, calendar: <>, message: <>, shield: <>, beaker: <>, package: <>, layers: <>, home: <>, clock: <>, bookmark: <>, arrow_up_right: <>, }; window.RAlly = window.RAlly || {}; // ── 데모 모드: ON이면 시드(더미) 데이터 표시, OFF(기본)면 실서비스(실데이터만) ── window.RAlly.isDemo = () => { try { return localStorage.getItem('RALLY_DEMO') === '1'; } catch (e) { return false; } }; window.RAlly.setDemo = (b) => { try { localStorage.setItem('RALLY_DEMO', b ? '1' : '0'); } catch (e) {} location.reload(); }; // 선택 트랙이 없으면 실제 생성 트랙(profile 보유·최신)을 해석. 시드 트랙 1로 폴백하지 않음. window.RAlly.resolveTrackId = () => { const t = (typeof window !== 'undefined' && window.__RALLY_TRACK); if (t) return Promise.resolve(t); return fetch('/api/tracks').then((r) => r.json()).then((d) => { const ts = d.tracks || []; const pick = ts.slice(-1)[0]; // 실모드에선 서버가 실제 트랙만 반환 const id = pick ? pick.id : null; if (id && typeof window !== 'undefined') window.__RALLY_TRACK = id; return id; }).catch(() => null); }; // /api/ 요청 공통 처리: // · 세션 쿠키 전송(credentials: same-origin) — 서버가 신원·역할·조직을 세션에서 도출한다. // · 데모 모드 → demo=1 (서버가 내 회사 + 시드 데이터를 반환) // · 401(미인증) → 로그인 화면으로 되돌리는 전역 이벤트 발행 // ※ org_id는 보내더라도 서버가 세션 값으로 덮어쓴다(클라이언트 값 불신). 여긴 보내지 않는다. (function () { if (window.__RALLY_FETCH_PATCHED) return; window.__RALLY_FETCH_PATCHED = true; const _f = window.fetch.bind(window); const AUTH_PATHS = ['/api/auth/login', '/api/auth/me', '/api/auth/logout']; window.fetch = (url, opts) => { let u = url; const o = Object.assign({}, opts || {}); o.credentials = o.credentials || 'same-origin'; // 세션 쿠키 전송 try { const isGet = !o.method || String(o.method).toUpperCase() === 'GET'; if (typeof u === 'string' && u.indexOf('/api/') === 0 && isGet && window.RAlly.isDemo() && u.indexOf('demo=') < 0) { u += (u.indexOf('?') >= 0 ? '&' : '?') + 'demo=1'; } } catch (e) {} return _f(u, o).then((res) => { try { const isAuthPath = typeof u === 'string' && AUTH_PATHS.some((p) => u.indexOf(p) === 0); if (res.status === 401 && !isAuthPath) { window.RAlly.clearUser(); window.dispatchEvent(new CustomEvent('rally:unauthorized')); } } catch (e) {} return res; }); }; })(); window.RAlly.Icon = Icon; // ---------- Pill ---------- const Pill = ({ children, variant = 'default', size, className = '', ...rest }) => { const cls = ['pill', variant !== 'default' && `pill--${variant}`, size === 'lg' && 'pill--lg', className] .filter(Boolean).join(' '); return {children}; }; // ---------- Tag (uppercase micro) ---------- const Tag = ({ children, variant, className = '' }) => { const cls = ['tag', variant && `tag--${variant}`, className].filter(Boolean).join(' '); return {children}; }; // ---------- Avatar ---------- const Avatar = ({ initials, variant, size, title }) => { const cls = ['avatar', variant && `avatar--${variant}`, size && `avatar--${size}`].filter(Boolean).join(' '); return {initials}; }; // ---------- Button ---------- const Button = ({ variant = 'ghost', size, icon, iconRight, children, className = '', ...rest }) => { const cls = ['btn', `btn--${variant}`, size && `btn--${size}`, className].filter(Boolean).join(' '); return ( ); }; // ---------- Progress ---------- const Progress = ({ value, size, onNavy }) => { const cls = ['track', size === 'sm' && 'track--sm', onNavy && 'track--on-navy'] .filter(Boolean).join(' '); return
; }; // ---------- KPI card ---------- const KPI = ({ label, value, valueColor, meta, metaTone }) => (
{label}
{value}
{meta &&
{meta}
}
); Object.assign(window.RAlly, { Icon, Pill, Tag, Avatar, Button, Progress, KPI }); // ── 현재 사용자 — 신원의 근거는 서버 세션(HttpOnly 쿠키)이다 ──────────────── // localStorage(rally_user)는 화면 표시용 '캐시'일 뿐 권한의 근거가 아니다. // 권한·역할·조직 판정은 전부 서버가 세션에서 도출한다(클라이언트 값 불신). // 데모 계정은 존재하지 않는다 — 실계정(이메일+비밀번호)만 로그인 가능. window.RAlly.fetchMe = () => fetch('/api/auth/me', { credentials: 'same-origin' }) .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (d && d.ok && d.user) { window.RAlly.setUser(d.user); return d.user; } window.RAlly.clearUser(); return null; }) .catch(() => null); window.RAlly.login = (email, password) => fetch('/api/auth/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }).then((r) => r.json().then((d) => ({ status: r.status, data: d }))); window.RAlly.logout = () => fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }) .catch(() => {}) .then(() => { window.RAlly.clearUser(); }); window.RAlly.getOrgId = () => { const u = window.RAlly.getUser(); return (u && u.orgId) || 0; }; window.RAlly.getUser = () => { try { return JSON.parse(localStorage.getItem('rally_user')) || null; } catch (e) { return null; } }; // ── 역할별 문서 담당(개발자=AUTHOR가 작성하는 개발 문서 셋) ── // 백엔드 doc_role 게이트와 동일 계약. 개발자는 이 문서종만 작성/저장 가능. window.RAlly.DEV_DOCS = ['DMR', 'SRS', 'SDS', 'SVV', 'SVR', 'TVR', 'VAR', 'TRC', 'ADM', 'AMS', 'APM', 'CSC', 'DIR', 'DOR']; // 개발자(AUTHOR)면 개발 문서만 허용, 그 외 역할(RA/ADMIN)은 전체 허용. window.RAlly.canAuthorDoc = (role, docType) => role !== 'AUTHOR' || window.RAlly.DEV_DOCS.includes(docType); window.RAlly.setUser = (u) => { try { localStorage.setItem('rally_user', JSON.stringify(u)); } catch (e) {} }; window.RAlly.clearUser = () => { try { localStorage.removeItem('rally_user'); } catch (e) {} };