// track_detail.jsx — T-02 트랙 상세 const { Icon, Pill, Tag, Avatar, Button, Progress, KPI } = window.RAlly; const TRACK_STAGES = [ { num: 'STAGE 01', title: '출발', meta: '제품 정의 · 의도된 용도 · 규정 매핑', icon: 'flag' }, { num: 'STAGE 02', title: '본격 주행', meta: 'SOP · 위험관리 · 기술문서 작성', icon: 'run' }, { num: 'STAGE 03', title: '페이스 조절', meta: '설계 검증 · 임상 평가 · 사용성 검증', icon: 'heart' }, { num: 'STAGE 04', title: '결승선', meta: 'MFDS 신청 패키지 · 제출', icon: 'trophy' }, ]; const deriveStages = (overall) => TRACK_STAGES.map((s, i) => { const start = i * 25; const pct = Math.max(0, Math.min(100, Math.round(((overall || 0) - start) / 25 * 100))); const statusKey = (overall || 0) >= start + 25 ? 'done' : ((overall || 0) >= start ? 'current' : 'waiting'); return { ...s, progress: pct, statusKey, status: statusKey === 'done' ? '완주' : (statusKey === 'current' ? ('진행 중 · ' + pct + '%') : '대기') }; }); const TrackDetail = ({ go }) => { const [data, setData] = React.useState(null); React.useEffect(() => { const load = (tid) => fetch('/api/track/' + tid + '/detail').then((r) => r.json()).then(setData).catch(() => {}); const sel = (typeof window !== 'undefined' && window.__RALLY_TRACK); if (sel) { load(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) { if (typeof window !== 'undefined') window.__RALLY_TRACK = pick.id; load(pick.id); } }).catch(() => {}); }, []); const t = (data && data.track) || { name: '트랙 로딩 중…', stage: 'STAGE 01 · 출발', progress: 0, deadline: null, meta: '' }; const bd = (data && data.breakdown) || []; const stages = deriveStages(t.progress); const curIdx = Math.min(3, Math.floor((t.progress || 0) / 25)); return (
{/* Header */}
{t.stage} 디지털의료기기 소프트웨어 디지털의료제품법 / IEC 62304 / ISO 14971 / ISO 13485
{t.name}
{t.meta || '품목 정보 기준 적용 문서 트랙'}
{/* Navy banner with track */}
CURRENT POSITION
{t.progress}%
주행 완료 · 결승선까지 약 {Math.max(0, 4 - curIdx - 1)} STAGE
EXPECTED FINISH
{t.deadline || '미정'}
MFDS 품목허가 목표
{/* Banner track with 5 notches at 0/25/50/75/100 */}
{[{ label: '출발' }, ...stages.map((s, i) => ({ label: i === 3 ? '결승선' : ('Stage 0' + (i + 1)) }))].map((n, i) => { // i=0 출발(항상 done), i=1..4 = stage 1..4 const overall = t.progress || 0; const done = i === 0 ? true : overall >= i * 25; const cur = i > 0 && !done && overall >= (i - 1) * 25; const cls = done ? ' banner-track__notch--done' : (cur ? ' banner-track__notch--current' : ''); const lcls = done ? ' banner-track__label--done' : (cur ? ' banner-track__label--current' : ''); return (
{done && }
{n.label}{cur ? ' · 현재' : ''}
); })}
{/* 규제 라이프사이클 — 능동형 가이드(지금 할 일·규제 일정·변경 관리) */} {/* 4 STAGE cards (실제 진행률 구동) */}
{stages.map((s, i) => ( go('kanban')) : undefined} cta={i === curIdx ? '문서 보기' : undefined} /> ))}
{/* Current stage detail */}
현재 구간
{stages[curIdx].num} · {stages[curIdx].title}
{bd.map((b) => ( ))}
{/* Sub-track strip: lane visualization of activity */}
트랙 활동 (지난 7일)
완주 진행 정체
); }; // ── 규제 라이프사이클 패널: 지금 할 일 + 규제 일정 + 변경 관리 (능동형) ── const LEVEL_STYLE = { urgent: { color: 'var(--coral)', bg: 'var(--coral-soft, #FBEAE4)', icon: 'alert' }, todo: { color: 'var(--navy)', bg: 'var(--surface-2)', icon: 'arrow_right' }, input: { color: 'var(--ink-2)', bg: 'var(--surface-2)', icon: 'edit' }, info: { color: 'var(--green)', bg: 'var(--green-soft)', icon: 'check' }, }; const LifecyclePanel = ({ go }) => { const [tid, setTid] = React.useState(null); const [guide, setGuide] = React.useState(null); const [cal, setCal] = React.useState(null); const [events, setEvents] = React.useState([]); const [picked, setPicked] = React.useState({}); const [assess, setAssess] = React.useState(null); const [dates, setDates] = React.useState({ rev_date: '', gmp_cert_date: '', license_date: '' }); const [cons, setCons] = React.useState(null); const loadAll = (id) => { fetch('/api/track/' + id + '/guide').then((r) => r.json()).then(setGuide).catch(() => {}); fetch('/api/track/' + id + '/calendar').then((r) => r.json()).then(setCal).catch(() => {}); }; React.useEffect(() => { window.RAlly.resolveTrackId().then((id) => { if (!id) return; setTid(id); loadAll(id); // 날짜 디폴트: 제·개정일=작성일(생성일) fetch('/api/track/' + id + '/dates/defaults').then((r) => r.json()).then((d) => { if (d.ok) setDates((p) => ({ ...p, ...d.defaults })); }).catch(() => {}); }); fetch('/api/change/events').then((r) => r.json()).then((d) => setEvents(d.events || [])).catch(() => {}); }, []); const postDates = (patch) => { if (!tid) return; fetch('/api/track/' + tid + '/dates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }).then((r) => r.json()).then((d) => { setCal(d); setCons(d.consistency || null); loadAll(tid); }).catch(() => {}); }; const saveDate = () => postDates({ ...dates }); const applyChoice = (field, value) => { setDates((p) => ({ ...p, [field]: value })); postDates({ [field]: value }); }; const setDate = (k, v) => setDates((p) => ({ ...p, [k]: v })); const runAssess = () => { const ids = Object.keys(picked).filter((k) => picked[k]); if (!tid || !ids.length) { setAssess(null); return; } fetch('/api/change/assess', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_ids: ids, track_id: tid }) }).then((r) => r.json()).then(setAssess).catch(() => {}); }; const nx = guide && guide.next; const actions = (nx && nx.actions) || []; // action.kind별 실제 화면 라우팅(죽은 CTA → 흐름 배선) const dateCardRef = React.useRef(null); const routeAction = (a) => { const k = a && a.kind; if (k === 'inputs') return go && go('fill'); if (k === 'ready') return go && go('cert'); if (k === 'calendar' || k === 'calendar_input') { if (dateCardRef.current) dateCardRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } return go && go('kanban'); // 'docs' 및 기본 }; return (
{/* 지금 할 일 */}
지금 할 일
{nx && nx.summary && ( 진행 {nx.summary.progress}% · 미완 {nx.summary.undone} · 입력 {nx.summary.inputs_remaining} )}
{actions.length === 0 &&
준비 항목을 불러오는 중…
}
{actions.map((a, i) => { const s = LEVEL_STYLE[a.level] || LEVEL_STYLE.info; return (
{a.icon}
{a.title}
{a.detail}
{a.cta && }
); })}
{/* 규제 일정 + 날짜 정합성 */}
규제 일정 · 날짜
{cal && cal.events && cal.events.length > 0 && cal.events.map((e, i) => (
{e.dday_label}
{e.title} · {e.status}
기한 {e.due_date}
))} {/* 날짜 입력(작성일 디폴트, 수정 가능) */}
{[['rev_date', '제·개정일 (작성일 기본)'], ['gmp_cert_date', 'GMP 적합인정일'], ['license_date', '인허가일']].map(([k, label]) => (
{label} setDate(k, e.target.value)} placeholder="YYYY.MM.DD" style={{ flex: 1, padding: '6px 9px', border: '1px solid var(--line)', borderRadius: 6, fontSize: 12.5 }} />
))}
{/* 정합성 결과 */} {cons && (
{cons.summary}
{(cons.issues || []).map((it, i) => (
{it.message}
{it.choices.map((ch, j) => ( ))}
))}
)}
{/* 변경 관리 */}
변경 관리
어떤 변경이 생겼나요? 영향 문서와 규제 경로를 알려드립니다.
{events.map((ev) => ( ))}
{assess && assess.ok && (
{assess.primary_route || '문서 갱신'} {assess.significant && 중대한 변경}
영향 문서 {(assess.impacted_docs || []).length}건:
{(assess.impacted_docs || []).map((d) => ( {d.label} · {d.exists ? '보강' : '생성'} ))}
)}
); }; const StageCard = ({ num, title, meta, status, statusKey, progress, icon, onClick, cta }) => (
{num}
{statusKey === 'done' && (
)}
{title}
{meta}
{status}
{cta && (
{cta} →
)}
); const SubKPI = ({ label, progress, done, total, meta, coral }) => (
{label}
{done}/{total}
{progress}%
{meta}
); // Simple GitHub-style activity lanes — 3 rows × 14 cells const ActivityLanes = () => { const rows = [ { label: 'SOP', cells: [1,1,2,1,2,3,1,2,3,3,2,1,2,3] }, { label: '위험관리', cells: [0,1,1,2,1,0,0,1,2,2,3,2,2,2] }, { label: '기술문서', cells: [0,0,1,1,1,2,1,1,2,3,2,1,3,3] }, ]; // 0 idle (line), 1 idle-light, 2 progress (coral), 3 done (green) const colors = ['var(--surface-3)', '#E8E2D6', 'var(--coral)', 'var(--green)']; const days = ['5/6','5/7','5/8','5/9','5/10','5/11','5/12','5/13','5/14','5/15','5/16','5/17','5/18','오늘']; return (
{days.map((d, i) => (
{d}
))}
{rows.map((row) => (
{row.label}
{row.cells.map((v, i) => (
))}
))}
); }; window.RAlly.TrackDetail = TrackDetail;