// intake.jsx — 고객 인테이크(온보딩): 회사 → 여정(신규/갱신) → 실행
const { Icon, Pill, Button, Progress } = window.RAlly;
// ── 판정 인터뷰: 앱이 질문을 하나씩 던져 정보를 받아 판정에 사용 ──────────
const IV_QUESTIONS = [
{ key: 'name', type: 'text', q: '제품(품목)의 이름은 무엇인가요?', ph: '예: 리스파이어 DTx',
why: '문서 표제부·품목명에 사용됩니다.', required: true },
{ key: 'indication', type: 'textarea', q: '제품의 용도와 핵심 기능을 설명해 주세요.',
ph: '예: 만성호흡기질환 가정 호흡재활 디지털치료기기 소프트웨어, AI 분석 포함',
why: '설명을 근거로 디지털·AI 여부를 1차 자동 판정합니다.' },
{ key: 'digital', type: 'choice', q: '소프트웨어 의료기기(SaMD·디지털)인가요?',
options: [['yes', '예'], ['no', '아니오'], ['unknown', '모르겠어요 — 설명으로 자동 판정']],
why: '별표2·별표3 적용 여부를 결정합니다.' },
{ key: 'ai', type: 'choice', q: '인공지능/머신러닝(AI/ML) 기능이 포함되나요?',
options: [['yes', '예'], ['no', '아니오'], ['unknown', '모르겠어요 — 설명으로 자동 판정']],
why: '별표4(AI 제어조치) 적용 여부를 결정합니다.' },
{ key: '등급', type: 'choice', q: '의료기기 등급은 무엇인가요?',
options: [['1', '1등급'], ['2', '2등급'], ['3', '3등급'], ['4', '4등급'], ['', '모르겠어요']],
why: '심사 범위·문서 구성을 결정합니다. (공식 품목분류표 기준 — 자동 판정 불가)' },
{ key: '품목분류', type: 'text', q: '품목분류번호를 알려주세요.', finder: true,
ph: '예: E01020.01 (모르면 아래 “찾기 도우미”로 좁혀보세요)',
why: '대상 품목을 확인합니다. 디지털 SW는 E코드(대분류 E·임상영역·기능)로 분류됩니다.' },
{ key: '심사구분', type: 'choice', q: '신규 취득인가요, 갱신 심사인가요?',
options: [['신규', '신규 취득'], ['갱신', '갱신 심사']],
why: '신규/갱신 워크플로를 분기합니다.' },
];
// 식약처 의료기기 소프트웨어(SaMD) 품목분류 — 대분류 E, 임상영역별 중분류 11개(웹 검증, 2025 고시).
// 소분류(끝번호)·등급은 임상영역+기능+등급 조합으로 정해지므로 식약처 확정 필요. 확인된 예시만 subs에 수록.
const SW_AREAS = [
{ code: 'E01000', name: '심혈관 진료용', kw: '심혈관·심장·심전도·혈관·ASCVD·순환기',
subs: [['E01010.01', '심혈관영상치료계획'], ['E01020.01', '심전도분석'], ['E01120.01', '심혈관영상검출·진단보조']] },
{ code: 'E02000', name: '치의학 진료용', kw: '치과·구강·치아',
subs: [['E02010.01', '치과영상전송(1등급)'], ['E02010.02', '치과영상전송(2등급)']] },
{ code: 'E03000', name: '이비인후과학 진료용', kw: '귀·코·목·청력·이비인후', subs: [] },
{ code: 'E04000', name: '위장병학·비뇨의학 진료용', kw: '소화기·위장·대장·비뇨·내시경', subs: [] },
{ code: 'E05000', name: '병원 진료용(일반)', kw: '병원일반·통합·범용', subs: [] },
{ code: 'E06000', name: '신경과학 진료용', kw: '뇌·신경·뇌파·EEG·뇌졸중', subs: [] },
{ code: 'E07000', name: '산부인과학 진료용', kw: '산부인과·태아·자궁·임신', subs: [] },
{ code: 'E08000', name: '안과학 진료용', kw: '눈·안과·망막·안저·시력', subs: [] },
{ code: 'E09000', name: '정형외과학 진료용', kw: '뼈·관절·척추·정형외과·골', subs: [] },
{ code: 'E10000', name: '재활의학 진료용', kw: '재활·물리치료·운동', subs: [] },
{ code: 'E11000', name: '방사선종양학·영상의학 진료용', kw: '영상의학·CT·MRI·X선·방사선치료·판독', subs: [] },
];
const SW_FUNCS = [
['검출·진단보조', '영상/데이터에서 이상을 검출해 진단 보조 (…검출·진단보조소프트웨어)'],
['치료계획', '치료 계획 수립·보조 (…영상치료계획소프트웨어)'],
['신호분석', '생체신호(심전도·뇌파 등) 분석 (…분석소프트웨어)'],
['영상전송·저장', '영상 전송·저장·표시 (…영상전송장치소프트웨어)'],
['기타·모름', '위에 해당하지 않거나 확실치 않음'],
];
const _fbtn = (on) => ({
fontFamily: 'inherit', fontSize: 11.5, padding: '5px 9px', borderRadius: 6, cursor: 'pointer',
border: `1px solid ${on ? 'var(--coral)' : 'var(--line)'}`,
background: on ? 'var(--coral-soft)' : 'var(--surface)', color: on ? 'var(--coral-2)' : 'var(--ink)',
});
// 품목 찾기 도우미: 임상영역+기능을 물어 중분류(E코드) 후보로 좁힘. 소분류·등급은 식약처 확정.
const ClassFinder = ({ onPick }) => {
const [area, setArea] = React.useState(null);
const [func, setFunc] = React.useState(null);
const a = SW_AREAS.find((x) => x.code === area);
return (
품목 찾기 도우미
디지털 소프트웨어(SaMD)는 대분류 E(소프트웨어) · 임상영역별 중분류 · 기능별 소분류로 나뉩니다. 아래에서 좁혀보세요.
① 어느 임상영역인가요?
{SW_AREAS.map((x) => (
))}
{area && (
② 핵심 기능은?
{SW_FUNCS.map(([k, desc]) => (
))}
)}
{area && (
대분류 E · 의료기기 소프트웨어(SaMD)
중분류 후보 {a.code} · {a.name} 소프트웨어
{a.subs.length > 0 ? (
이 영역의 실제 소분류 예: {a.subs.map(([c, n]) => c + ' ' + n).join(' · ')}
) : (
이 영역의 소분류(끝번호)는 식약처 품목분류에서 확인하세요.
)}
⚠ 정확한 소분류 끝번호·등급은 임상영역+기능+등급 조합으로 정해집니다. 지어내지 않습니다 — 식약처 의료기기 품목분류에서 최종 확인하세요.
식약처 품목분류 조회 ↗
)}
);
};
// 임상 필요성 '안내'(참고용) — 확정 아님. Q1~Q4 → 동등/개량/신제품 경로 안내.
const CG_TONE = {
green: { bg: '#EAF7EF', bd: '#22C55E', fg: '#15924A' },
amber: { bg: '#FFF6E5', bd: '#F5A623', fg: '#9A6800' },
coral: { bg: 'var(--coral-soft)', bd: 'var(--coral)', fg: 'var(--coral-2)' },
};
const CG_DS = {
req: { t: '필수', c: '#15924A', b: '#EAF7EF' },
cond: { t: '조건부', c: '#9A6800', b: '#FFF6E5' },
exempt: { t: '면제', c: 'var(--ink-3)', b: '#F1F2F4' },
};
const ClinicalGuide = ({ aiHint }) => {
const [open, setOpen] = React.useState(false);
const [ans, setAns] = React.useState(() => (aiHint ? { ai_decision: 'yes' } : {}));
const [res, setRes] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const set = (k, v) => { setAns((s) => ({ ...s, [k]: v })); setRes(null); };
const canAssess = ans.similar_exists === 'no'
|| (ans.similar_exists === 'yes' && ans.same_purpose === 'no')
|| (ans.similar_exists === 'yes' && ans.same_purpose === 'yes' && !!ans.diff_level);
const run = () => {
setBusy(true);
fetch('/api/clinical-guide', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: ans }),
}).then((r) => r.json()).then((d) => { setRes(d); setBusy(false); }).catch(() => setBusy(false));
};
const Q = ({ label, k, opts }) => (
{label}
{opts.map(([v, t]) => )}
);
return (
{!open &&
“우리도 임상해야 하나요?”를 몇 가지 질문으로 안내합니다. (확정 아님)
}
{open && (
{/* 안내 원칙(면책) 배너 */}
⚠ RAlly는 임상 필요 여부를 확정하지 않고 안내만 합니다. 최종 판단은 식약처 심사에서 이루어지며, 아래는 참고용입니다. RAlly는 GMP 문서를 생성하되 임상시험·허가 제출 자료는 생성하지 않습니다.
{ans.similar_exists === 'yes' && (
)}
{ans.similar_exists === 'yes' && ans.same_purpose === 'yes' && (
)}
{!canAssess && Q1~Q3에 답하면 경로를 안내합니다.}
{res && res.complete && (
{res.branch_label}
{res.headline}
{res.clinical}
{res.ai_note && (
🧠 {res.ai_note}
)}
이 경로에서 필요할 수 있는 문서 (안내)
{res.docs.map((d, i) => (
))}
근거 · {(res.basis || []).join(' / ')}
{res.disclaimer}
)}
)}
);
};
// ── 통합 프로필 입력 필드(회사·제품 전항목) ─────────────────────────
const _inp = { width: '100%', fontFamily: 'inherit', fontSize: 13, padding: '7px 10px', border: '1px solid var(--line)', borderRadius: 7 };
const ProfileField = ({ f, value, onChange, note }) => (
{f.label}
{f.multiline ? (
);
// 회사·제품 전항목 폼 — 그룹별 접이식. 통합 인테이크: 처음에 모두 입력 → 전 문서 자동 반영.
const ProfileForm = ({ fields, values, onChange, title, hint, notes }) => {
const [open, setOpen] = React.useState(true);
const groups = {};
(fields || []).forEach((f) => { (groups[f.group] = groups[f.group] || []).push(f); });
const filled = (fields || []).filter((f) => String(values[f.key] || '').trim()).length;
return (
{open && (
{hint &&
{hint}
}
{Object.entries(groups).map(([g, fs]) => (
))}
)}
);
};
// 기존 문서 업로드 → 슬롯 후보 추출 → 확인·반영(무확인 자동반영 금지)
const ExtractUploader = ({ onApply }) => {
const [files, setFiles] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [res, setRes] = React.useState(null);
const [picked, setPicked] = React.useState({});
const run = () => {
if (!files || !files.length) { alert('제품표준서·기존 문서(.docx/.hwpx)를 선택하세요'); return; }
setBusy(true); setRes(null);
const fd = new FormData();
Array.from(files).forEach((f) => fd.append('files', f));
fetch('/api/intake/extract', { method: 'POST', body: fd })
.then((r) => r.json()).then((d) => {
setRes(d);
const pk = {}; Object.keys(d.candidates || {}).forEach((k) => { pk[k] = true; });
setPicked(pk); setBusy(false);
}).catch(() => { setRes({ ok: false, error: '추출 실패' }); setBusy(false); });
};
const apply = () => {
const vals = {};
Object.entries(res.candidates || {}).forEach(([k, v]) => { if (picked[k]) vals[k] = v.value; });
onApply(vals);
};
const cand = res && res.candidates ? Object.values(res.candidates) : [];
return (
📎 기존 문서로 자동 채우기 (선택)
제품표준서·기존 심사문서(.docx/.hwpx)를 올리면 회사·제품 정보를 추출합니다. 추출값은 후보이며, 확인·수정 후 반영됩니다(자동 반영 안 함).
setFiles(e.target.files)} style={{ fontSize: 12 }} />
{res && res.ok && cand.length > 0 && (
추출된 후보 {cand.length}건 — 반영할 항목을 선택하세요
{cand.map((v) => (
))}
)}
{res && res.ok && cand.length === 0 &&
추출된 후보가 없습니다(라벨 인식 실패).
}
{res && !res.ok &&
{res.error}
}
);
};
const Interview = ({ onDone, classifying }) => {
const [idx, setIdx] = React.useState(0);
const [ans, setAns] = React.useState({});
const [finderOpen, setFinderOpen] = React.useState(false);
React.useEffect(() => { setFinderOpen(false); }, [idx]); // 질문 넘어가면 도우미 접기
const q = IV_QUESTIONS[idx];
const last = idx === IV_QUESTIONS.length - 1;
const val = ans[q.key] === undefined ? '' : ans[q.key];
const set = (v) => setAns((s) => ({ ...s, [q.key]: v }));
const canNext = !q.required || String(val).trim();
const next = () => { if (last) onDone(ans); else setIdx((i) => i + 1); };
const prev = () => setIdx((i) => Math.max(0, i - 1));
const pct = Math.round(((idx + 1) / IV_QUESTIONS.length) * 100);
return (
① 판정을 위한 질문 {idx + 1} / {IV_QUESTIONS.length}
{pct}%
{q.q}
{q.why}
{q.type === 'text' && (
set(e.target.value)} placeholder={q.ph} autoFocus
onKeyDown={(e) => { if (e.key === 'Enter' && canNext) next(); }}
style={{ width: '100%', fontFamily: 'inherit', fontSize: 14, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8 }} />
)}
{q.finder && (
{finderOpen && { set(v); setFinderOpen(false); }} />}
)}
{q.type === 'textarea' && (
{idx > 0 && }
{q.type === 'choice' && !q.required && (
선택 후 ‘다음’을 누르세요.
)}
);
};
const Intake = ({ go }) => {
const [step, setStep] = React.useState('org'); // org → journey → run
const [orgs, setOrgs] = React.useState([]);
const [org, setOrg] = React.useState(null);
const [orgName, setOrgName] = React.useState('');
const [journey, setJourney] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [result, setResult] = React.useState(null);
const [file, setFile] = React.useState(null);
const [prod, setProd] = React.useState({ name: '', indication: '', digital: true, ai: true });
const [useLlm, setUseLlm] = React.useState(true); // 기본 ON: claude -p 제품맞춤 확장(백엔드 없으면 아래 effect가 OFF 폴백)
const [production, setProduction] = React.useState(false); // false=미리보기(가상채움), true=제출용([입력필요])
const [backends, setBackends] = React.useState([]);
const [cls, setCls] = React.useState(null); // 판정 결과
const [classifying, setClassifying] = React.useState(false);
const [extra, setExtra] = React.useState({}); // needs_info 답변(등급·품목분류·심사구분)
const [schema, setSchema] = React.useState({ company_fields: [], product_fields: [] });
const [company, setCompany] = React.useState({}); // 회사 전항목 프로필
const [prodExtra, setProdExtra] = React.useState({}); // 제품 추가 항목(버전·안전등급·플랫폼 등)
React.useEffect(() => {
fetch('/api/orgs').then((r) => r.json()).then((d) => setOrgs(d.orgs || [])).catch(() => {});
fetch('/api/profile/schema').then((r) => r.json()).then((d) => setSchema(d)).catch(() => {});
fetch('/api/llm-writer/backends').then((r) => r.json()).then((d) => {
const bs = d.backends || [];
setBackends(bs);
if (!bs.length) setUseLlm(false); // LLM 백엔드(claude CLI) 없으면 기본 OFF 폴백 — 생성은 계속 가능
}).catch(() => setUseLlm(false));
}, []);
// 회사 선택 시 저장된 회사 프로필 불러오기(재사용)
React.useEffect(() => {
if (org && org.id) {
fetch('/api/org/' + org.id + '/profile').then((r) => r.json())
.then((d) => { if (d.ok && d.profile) setCompany((c) => ({ ...d.profile, ...c })); }).catch(() => {});
}
}, [org]);
// ── 문서번호 접두(doc_prefix): 회사명에서 자동 제안 + 사용자 수정 가능 ──
// 사용자가 직접 손대면(prefixTouched) 자동 제안이 그 값을 덮어쓰지 않는다(사용자 지정 우선).
const [prefixTouched, setPrefixTouched] = React.useState(false);
const [prefixSuggest, setPrefixSuggest] = React.useState('');
const setCompanyField = (k, v) => {
if (k === 'doc_prefix') {
setPrefixTouched(true); // 직접 입력 → 자동 제안 중단
v = String(v || '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4);
}
setCompany((s) => ({ ...s, [k]: v }));
};
const setProdField = (k, v) => setProdExtra((s) => ({ ...s, [k]: v }));
// 회사명(국문/영문) 변경 → 접두 제안 조회. 사용자가 아직 수정하지 않았으면 자동 채움.
const cKo = company.company_name_ko || '';
const cEn = company.company_name_en || '';
React.useEffect(() => {
if (!cKo && !cEn) { setPrefixSuggest(''); return; }
const q = new URLSearchParams({ company_name_ko: cKo, company_name_en: cEn });
fetch('/api/profile/doc-prefix?' + q).then((r) => r.json()).then((d) => {
if (!d.ok) return;
setPrefixSuggest(d.suggested || '');
if (!prefixTouched) setCompany((s) => ({ ...s, doc_prefix: d.suggested || '' }));
}).catch(() => {});
}, [cKo, cEn, prefixTouched]);
// 저장된 회사 프로필에 접두가 이미 있으면 사용자 지정으로 간주(자동 덮어쓰기 방지)
React.useEffect(() => {
if (company.doc_prefix && !prefixTouched && !prefixSuggest) setPrefixTouched(true);
}, [company.doc_prefix]);
const prefixNote = company.doc_prefix
? `문서번호 예: ${company.doc_prefix}-OP-M01 · ${company.doc_prefix}-RMP-001` +
(prefixTouched ? ' (직접 지정)' : ' (회사명에서 자동 제안 — 수정 가능)')
: '회사명을 입력하면 접두가 자동 제안됩니다. 직접 수정할 수 있습니다(영문 대문자 2~4자).';
// 추출 후보 반영: 회사/제품 스키마 키에 맞춰 분배
const applyExtracted = (vals) => {
const ckeys = new Set((schema.company_fields || []).map((f) => f.key));
const pkeys = new Set((schema.product_fields || []).map((f) => f.key));
const cpatch = {}, ppatch = {};
Object.entries(vals).forEach(([k, v]) => {
if (ckeys.has(k)) cpatch[k] = v;
else if (pkeys.has(k)) ppatch[k] = v;
});
if (Object.keys(cpatch).length) setCompany((s) => ({ ...s, ...cpatch }));
if (Object.keys(ppatch).length) {
setProdExtra((s) => ({ ...s, ...ppatch }));
if (ppatch.name) setProd((p) => ({ ...p, name: ppatch.name }));
if (ppatch.indication) setProd((p) => ({ ...p, indication: ppatch.indication }));
}
};
const createOrg = () => {
if (!orgName.trim()) return;
fetch('/api/orgs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: orgName.trim() }) })
.then((r) => r.json()).then((d) => { if (d.ok) { setOrg({ id: d.id, name: d.name }); setStep('journey'); } });
};
const pickOrg = (o) => { setOrg(o); setStep('journey'); };
const _profile = () => ({
...prodExtra, // 제품 추가 항목(버전·안전등급·플랫폼·OS·언어·프레임워크 등)
name: prod.name || null, brand: prod.name || null, indication: prod.indication || prod.name || null,
digital_specific: prod.digital, ai: prod.ai,
등급: extra['등급'] || prodExtra['등급'] || null, 품목분류: extra['품목분류'] || prodExtra['품목분류'] || null,
심사구분: extra['심사구분'] || null,
});
const runClassify = () => {
if (!prod.name.trim()) { alert('제품명을 입력하세요'); return; }
setClassifying(true);
fetch('/api/classify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile: _profile(), use_llm: true }) })
.then((r) => r.json()).then((d) => {
setCls(d);
// 판정 결과로 디지털/AI 체크 동기화
setProd((p) => ({ ...p, digital: d.digital, ai: d.ai }));
setClassifying(false);
}).catch(() => setClassifying(false));
};
// 인터뷰 답변 → 프로필 구성 → 판정 실행
const handleInterview = (a) => {
setProd((p) => ({ ...p, name: a.name || '', indication: a.indication || '' }));
setExtra({ '등급': a['등급'] || '', '품목분류': a['품목분류'] || '', '심사구분': a['심사구분'] || '' });
setClassifying(true);
const profile = {
name: a.name || null, brand: a.name || null, indication: a.indication || a.name || null,
digital_specific: a.digital === 'yes' ? true : a.digital === 'no' ? false : null,
ai: a.ai === 'yes' ? true : a.ai === 'no' ? false : null,
'등급': a['등급'] || null, '품목분류': a['품목분류'] || null, '심사구분': a['심사구분'] || null,
};
fetch('/api/classify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile, use_llm: true }) })
.then((r) => r.json()).then((d) => {
setCls(d);
setProd((p) => ({ ...p, name: a.name || '', indication: a.indication || '', digital: d.digital, ai: d.ai }));
setClassifying(false);
}).catch(() => setClassifying(false));
};
const runNew = () => {
setBusy(true);
const body = { org_id: org.id, profile: _profile(), company, use_llm: useLlm, production, backend: backends[0] || 'cli' };
fetch('/api/intake/new', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then((r) => r.json()).then((d) => { setResult(d); setStep('run'); setBusy(false); }).catch(() => setBusy(false));
};
const runRenew = () => {
if (!file) { alert('기존 문서 ZIP을 선택하세요'); return; }
setBusy(true);
const fd = new FormData(); fd.append('file', file); fd.append('org_id', org.id); fd.append('org_name', org.name);
fetch('/api/intake/renew', { method: 'POST', body: fd })
.then((r) => r.json()).then((d) => { if (!d.ok) { alert(d.error); setBusy(false); return; } setResult(d); setStep('run'); setBusy(false); }).catch(() => setBusy(false));
};
const openKanban = () => { window.__RALLY_TRACK = result.track_id; go('kanban'); };
const openTrack = () => { window.__RALLY_TRACK = result.track_id; go('track'); };
const [reBusy, setReBusy] = React.useState(false);
const [reMsg, setReMsg] = React.useState(null);
const runReexpand = () => {
setReBusy(true); setReMsg(null);
fetch('/api/track/' + result.track_id + '/reexpand', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ production }),
}).then((r) => r.json()).then((d) => {
const n = (d.fixed || []).length;
setReMsg(n ? (n + '개 문서 확장 완료: ' + d.fixed.map((f) => f.code).join(', ')) : '추가 확장할(짧은) 문서가 없습니다.');
setReBusy(false);
}).catch(() => { setReMsg('재확장 실패 — 잠시 후 다시 시도하세요.'); setReBusy(false); });
};
const [dfBusy, setDfBusy] = React.useState(false);
const [dfMsg, setDfMsg] = React.useState(null);
const runDemoFill = () => {
setDfBusy(true); setDfMsg(null);
fetch('/api/track/' + result.track_id + '/demofill', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
}).then((r) => r.json()).then((d) => {
setDfMsg((d.docs || 0) + '개 문서 · ' + (d.markers || 0) + '개 [입력 필요]를 예시값으로 채웠습니다 (모두 "예시·실측 아님" 표기).');
setDfBusy(false);
}).catch(() => { setDfMsg('데모 채우기 실패.'); setDfBusy(false); });
};
return (
RAlly 시작하기
회사를 만들고, 신규 GMP 준비 또는 기존 문서 갱신 분석을 시작하세요. 데이터는 회사별로 분리됩니다.
{/* Stepper */}
{[['org', '1 회사'], ['journey', '2 여정'], ['run', '3 실행']].map(([k, label]) => (
{label}
))}
{/* Step 1: org */}
{step === 'org' && (
회사(테넌트) 만들기
setOrgName(e.target.value)} placeholder="회사명 (예: 가온메디텍 주식회사)"
style={{ flex: 1, fontFamily: 'inherit', fontSize: 14, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8 }} />
{orgs.length > 0 && (
또는 기존 회사 선택
{orgs.map((o) => (
))}
)}
)}
{/* Step 2: journey */}
{step === 'journey' && (
회사: {org.name}
setJourney('new')}>
신규 GMP 준비
품목 정보만 가져오면, 적용 문서종을 모두 생성해 칸반에 올려드립니다.
setJourney('renew')}>
GMP 갱신 분석
기존 문서를 올리면 전부 분석해 지금 무엇을 보완해야 하는지 알려드립니다.
{journey === 'new' && !cls && (
)}
{journey === 'new' && cls && (
① 판정 결과
{/* 판정 결과 */}
{cls && (
판정: {cls.verdict}
디지털 {cls.digital ? 'O' : 'X'} ({cls.digital_source}) · AI {cls.ai ? 'O' : 'X'}
{cls.annexes.map((a, i) =>
{a})}
{/* 수집된 품목 정보 요약 */}
품목명 · {prod.name || '—'}
품목분류번호 · {extra['품목분류'] || 미입력 — 문서에 [입력 필요]로 표기}
등급 · {extra['등급'] ? extra['등급'] + '등급' : '미입력'} | 심사구분 · {extra['심사구분'] || '미입력'}
{/* 필요 정보 요청 */}
{cls.needs_info && cls.needs_info.length > 0 && (
⚠ 판정·문서 작성에 필요한 정보를 알려주세요
{cls.needs_info.map((n, i) => (
))}
입력 후 ‘다시 판정’ 또는 바로 ‘전 문서 생성’. 미입력 값은 문서에 [입력 필요]로 표시됩니다.
)}
)}
{/* 임상 필요성 안내(참고용) — 확정 아님, 안내만 */}
{cls &&
}
{/* ② 회사·제품 정보 입력(전 문서 자동 반영) */}
{cls && (
② 회사·제품 정보 입력
여기 입력한 회사·제품 정보는 필요한 모든 문서에 자동 반영됩니다(표제부·승인란·바닥글·본문 [입력 필요]).
한 번만 입력하면 다음 트랙에도 재사용됩니다. 시험결과·성능·서명 등 실측값은 계속 직접 채웁니다.
!['name', 'indication', 'ai', '등급', '품목분류'].includes(f.key))}
hint="모델명·버전·안전등급·플랫폼·OS·개발언어 등 — 제품표준서·SRS·SDS 등에 반영됩니다. (제품명·사용목적·등급·품목분류·AI는 위 판정에서 사용)" />
)}
{/* ③ 생성 */}
{cls && (
)}
)}
{journey === 'renew' && (
)}
)}
{/* Step 3: run/result */}
{step === 'run' && result && (
{result.journey === 'new' ? (
✅ {result.brand} · 신규 준비 완료
{result.use_llm ? '🤖 LLM 작성' : '템플릿'}
적용 문서 {result.doc_count}개를 칸반 '시작 전'에 올렸습니다 · 채울 증거값 {result.input_count}개
{result.applicable_docs.map((d) => d.code).join(', ')}
{result.use_llm && (
LLM 확장은 대량 생성 시 일부 문서가 호출 한도로 빠질 수 있어요. 아래 버튼으로 빠진 문서만 이어서 확장합니다.
{reMsg &&
{reMsg}}
)}
데모용 — [입력 필요]를 예시값으로 채워 화면 시연. 모든 값에 "예시(실측 아님)" 표기가 붙어 실제 제출엔 쓸 수 없습니다(회사가 실값으로 교체).
{dfMsg &&
{dfMsg}}
) : (
✅ 갱신 분석 완료 — {result.doc_count}개 문서
이미 충족 {result.summary['이미충족']} · 보완 필요 {result.summary['보강됨']} · 검토 {result.summary['규칙없음']}
{result.to_supplement.length > 0 && (
⚠ 지금 보완해야 할 문서
{result.to_supplement.map((s, i) => (
{s.name}
[{s.doc_type}] 보완: {(s.gaps || []).join(', ') || s.supplemented.join(', ')}
))}
)}
)}
)}
);
};
window.RAlly.Intake = Intake;