// 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 */}
);
};
// ── 규제 라이프사이클 패널: 지금 할 일 + 규제 일정 + 변경 관리 (능동형) ──
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.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' && (
)}
{status}
{cta && (
{cta} →
)}
);
const SubKPI = ({ label, progress, done, total, meta, coral }) => (
);
// 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;