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