// ui.jsx — UROSYS shared primitives. Exports to window. const T = { blue: '#0A4FA3', blue600: '#073C7E', blueSurface: '#E8F0FA', blueLine: '#C2D7EE', navy: '#003B7E', cyan: '#00A0E0', up: '#D8232A', upS: '#FBEAEA', down: '#1F6FD0', downS: '#E9F1FB', ok: '#11865B', okS: '#E4F4EC', okL: '#BCE3CF', warn: '#D9822B', warnS: '#FBEFDD', warnL: '#F0D2A4', alert: '#C8363C', alertS: '#FBE8E8', alertL: '#F0C2C2', info: '#1F6FB2', infoS: '#E6F1F9', c900: '#0F1722', c800: '#1B2533', c700: '#2B3848', c600: '#475569', c500: '#64748B', c400: '#94A3B8', c300: '#CBD5E1', c250: '#DCE3EC', c200: '#E6EBF1', c150: '#EEF2F6', c100: '#F4F6F8', c50: '#F9FBFC', white: '#fff', line: '#E6EBF1', font: '"Pretendard",-apple-system,system-ui,sans-serif', mono: '"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace' }; const STATUS = { ok: { fg: T.ok, bg: T.okS, line: T.okL }, warn: { fg: T.warn, bg: T.warnS, line: T.warnL }, alert: { fg: T.alert, bg: T.alertS, line: T.alertL }, info: { fg: T.info, bg: T.infoS, line: '#CDE3F1' }, neutral: { fg: T.c600, bg: T.c150, line: T.c200 }, blue: { fg: T.blue600, bg: T.blueSurface, line: T.blueLine } }; function Card({ children, style = {}, pad = 0 }) { return
{children}
; } function CardHead({ title, sub, reg, right, icon }) { return (
{icon &&
}
{title}
{sub &&
{sub}
}
{reg && {reg}} {right}
); } function RegTag({ children }) { return {children}; } function Pill({ kind = 'neutral', children, dot, icon, small }) { const s = STATUS[kind]; return ( {dot && } {icon && } {children} ); } function Btn({ children, variant = 'primary', icon, iconR, onClick, size = 'md', style = {}, disabled }) { const pad = size === 'sm' ? '6px 11px' : size === 'lg' ? '11px 18px' : '8px 14px'; const fs = size === 'sm' ? 12.5 : 13.5; const v = { primary: { background: T.blue, color: '#fff', border: '1px solid ' + T.blue }, secondary: { background: T.white, color: T.c700, border: '1px solid ' + T.c250 }, ghost: { background: 'transparent', color: T.c600, border: '1px solid transparent' }, danger: { background: T.alert, color: '#fff', border: '1px solid ' + T.alert }, dark: { background: T.c800, color: '#fff', border: '1px solid ' + T.c800 } }[variant]; return ( ); } function Segmented({ options, value, onChange, size = 'md' }) { return (
{options.map((o) => { const v = typeof o === 'string' ? o : o.v,l = typeof o === 'string' ? o : o.l; const on = v === value; return ; })}
); } function Tabs({ options, value, onChange }) { return (
{options.map((o) => { const v = typeof o === 'string' ? o : o.v,l = typeof o === 'string' ? o : o.l,c = typeof o === 'object' ? o.count : null; const on = v === value; return ; })}
); } // KPI stat function Stat({ label, value, unit, sub, kind, reg }) { return (
{label}
{value} {unit && {unit}}
{sub &&
{sub}
}
); } // Maker-Checker workflow bar function MakerChecker({ state = 'approved', maker, checker }) { const steps = [ { k: 'draft', l: 'Draft' }, { k: 'submitted', l: 'Maker submitted' }, { k: 'review', l: 'Checker review' }, { k: 'approved', l: 'Approved' }]; const idx = steps.findIndex((s) => s.k === state); return (
{steps.map((s, i) => { const done = i <= idx,cur = i === idx; return (
{done && !cur ? : i + 1} {s.l}
{i < steps.length - 1 && }
); })}
); } // Data table — cols:[{k,t,w,num,align}], rows:[{...cells,_click,_total,_open}] function Table({ cols, rows, dense }) { const cellPad = dense ? '9px 14px' : '12px 16px'; return (
{cols.map((c) => )} {rows.map((r, i) => {if (r._click) e.currentTarget.style.background = T.c50;}} onMouseLeave={(e) => {if (r._click) e.currentTarget.style.background = r._total ? T.c50 : 'transparent';}}> {cols.map((c) => )} )}
{c.t}
{r[c.k]}
); } Object.assign(window, { T, STATUS, Card, CardHead, RegTag, Pill, Btn, Segmented, Tabs, Stat, MakerChecker, Table }); // ---- IBK additions ---- // status -> pill kind ; 정상 ok(green) / 유보 neutral(gray) / 주의 warn(orange) / 위험 alert(red) const STKIND = { '정상': 'ok', '안전': 'ok', '적정': 'blue', '유보': 'neutral', '주의': 'warn', '위험': 'alert' }; function StatusPill({ status, small }) { return {status}; } // directional number (KR convention: + red / − blue) function dirColor(v) {return v > 0 ? T.up : v < 0 ? T.down : T.c500;} function Delta({ v, suffix = '', digits = 2, arrow = true, size = 12.5 }) { const c = dirColor(v);const s = v > 0 ? '+' : ''; return {arrow && v !== 0 && 0 ? 'trendUp' : 'trendDown'} size={size} stroke={2.4} color={c} />}{s}{v.toFixed(digits)}{suffix}; } // search box function Search({ value, onChange, placeholder = '검색', w = 240 }) { return (
onChange(e.target.value)} placeholder={placeholder} style={{ border: 0, outline: 'none', flex: 1, minWidth: 0, fontFamily: T.font, fontWeight: 500, fontSize: 12.5, color: T.c800, background: 'transparent' }} /> {value && }
); } // filter chips (single-select) function Chips({ options, value, onChange }) { return (
{options.map((o) => {const v = typeof o === 'string' ? o : o.v,l = typeof o === 'string' ? o : o.l,c = typeof o === 'object' ? o.count : null;const on = v === value; return ; })}
); } // sortable data table. cols:[{k,t,w,num,align,render(row),sortVal(row)}]. sortable headers toggle. function DataTable({ cols, rows, initialSort, dense, onRow, maxH }) { const [sort, setSort] = React.useState(initialSort || { k: null, dir: 'desc' }); // cols를 ref로 관리 — 인라인 배열이 매 렌더마다 새 참조를 만들어도 // useMemo 의존성에서 제외해 불필요한 재정렬을 막음 const colsRef = React.useRef(cols); colsRef.current = cols; const sorted = React.useMemo(() => { if (!sort.k) return rows; const col = colsRef.current.find((c) => c.k === sort.k); if (!col) return rows; const val = col.sortVal || ((r) => r[sort.k]); const INF = sort.dir === 'asc' ? Infinity : -Infinity; return [...rows].sort((a, b) => { let x = val(a), y = val(b); // null/undefined → 항상 맨 아래 if (x == null && y == null) return 0; if (x == null) return 1; if (y == null) return -1; if (typeof x === 'string') return sort.dir === 'asc' ? x.localeCompare(y) : y.localeCompare(x); return sort.dir === 'asc' ? x - y : y - x; }); }, [rows, sort]); // cols 제외 — colsRef로 최신값 참조 const cellPad = dense ? '9px 13px' : '11px 15px'; // c.k(문자열)만 전달해 클로저 stale 문제 방지 const handleClick = React.useCallback((k, sortable) => { if (!sortable) return; setSort((s) => s.k === k ? { k, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { k, dir: 'desc' }); }, []); return (
{cols.map((c) => { const active = sort.k === c.k; const sortable = c.sortable !== false; return ( ); })} {sorted.map((r, i) => ( onRow(r) : undefined} style={{ cursor: onRow ? 'pointer' : 'default' }} onMouseEnter={(e) => { if (onRow) e.currentTarget.style.background = T.c50; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}> {cols.map((c) => ( ))} ))} {sorted.length === 0 && ( )}
handleClick(c.k, sortable)} style={{ width: c.w, textAlign: c.num ? 'right' : c.align || 'left', padding: '10px 15px', borderBottom: '1px solid ' + T.line, background: T.c50, fontFamily: T.font, fontWeight: 700, fontSize: 10.5, color: active ? T.blue : T.c500, letterSpacing: '.03em', textTransform: 'uppercase', whiteSpace: 'nowrap', position: 'sticky', top: 0, cursor: sortable ? 'pointer' : 'default', userSelect: 'none', zIndex: 1 }}> {c.t} {sortable && ( )}
{c.render ? c.render(r) : r[c.k]}
검색 결과가 없어요.
); } Object.assign(window, { STKIND, StatusPill, dirColor, Delta, Search, Chips, DataTable });