// 診断20問フォームの React コンポーネント // Babel Standalone 経由で評価されるため ESM ではなく React/ReactDOM をグローバルから取り出す const { useState, useEffect, useMemo, useRef } = React; const { createRoot } = ReactDOM; const PAGE_SIZE = 5; // 4件法。表示順は左端 (あてはまらない) → 右端 (あてはまる) // value: 採点用スコア(+2/+1/-1/-2)。中央は存在しないので reverse_question:true なら符号反転 // size: A 案デザインの両端強・中間弱 (likert-circle.size-strong / size-soft) const CHOICES = [ { value: '-2', label: '全くあてはまらない', size: 'strong' }, { value: '-1', label: 'あまりあてはまらない', size: 'soft' }, { value: '1', label: 'あてはまる', size: 'soft' }, { value: '2', label: '非常にあてはまる', size: 'strong' } ]; // answers: { questionId: '-2'|'-1'|'1'|'2' } // questions: [{id, axis, reverse_question, text}, ...] // axes: [{key, positive, negative}, ...] // 戻り値: 4文字の型コード(例 "IPLC") const calculateTypeCode = (answers, questions, axes) => { const sums = {}; axes.forEach((axis) => { sums[axis.key] = 0; }); questions.forEach((question) => { const raw = answers[question.id]; if (raw === undefined || raw === null || raw === '') return; let contribution = Number(raw); if (question.reverse_question) contribution = -contribution; const axis = axes.find((a) => a.key === question.axis); if (!axis) return; sums[axis.key] += contribution; }); return axes.map((axis) => (sums[axis.key] > 0 ? axis.positive : axis.negative)).join(''); }; const csrfToken = () => { const meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.content : ''; }; // Fisher-Yates シャッフル。元配列は変更せず、新しい配列を返す const shuffleArray = (array) => { const result = array.slice(); for (let i = result.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; }; const DiagnosisForm = ({ container }) => { const questionsUrl = container.dataset.questionsUrl; const mode = container.dataset.mode; const redirectBase = container.dataset.redirectBase; const submitUrl = container.dataset.submitUrl; const [questions, setQuestions] = useState(null); const [axes, setAxes] = useState(null); const [answers, setAnswers] = useState({}); const [loadError, setLoadError] = useState(null); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [pageIndex, setPageIndex] = useState(0); const [pageError, setPageError] = useState(null); const topRef = useRef(null); useEffect(() => { let cancelled = false; fetch(questionsUrl, { headers: { Accept: 'application/json' } }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => { if (cancelled) return; setQuestions(shuffleArray(data.questions || [])); setAxes(data.axes || []); }) .catch((err) => { if (cancelled) return; setLoadError(err.message || '読み込みに失敗しました'); }); return () => { cancelled = true; }; }, [questionsUrl]); const pages = useMemo(() => { if (!questions) return []; const result = []; for (let i = 0; i < questions.length; i += PAGE_SIZE) { result.push(questions.slice(i, i + PAGE_SIZE)); } return result; }, [questions]); const totalPages = pages.length; const isLastPage = totalPages > 0 && pageIndex === totalPages - 1; const isFirstPage = pageIndex === 0; const answeredCount = useMemo(() => { if (!questions) return 0; return questions.reduce((acc, q) => acc + (answers[q.id] ? 1 : 0), 0); }, [answers, questions]); const totalCount = questions ? questions.length : 0; const progressPercent = totalCount === 0 ? 0 : Math.round((answeredCount / totalCount) * 100); const handleChange = (questionId, value) => { setAnswers((prev) => ({ ...prev, [questionId]: value })); setPageError(null); }; const scrollToTop = () => { if (topRef.current && typeof topRef.current.scrollIntoView === 'function') { topRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; const currentPageAllAnswered = () => { const currentQuestions = pages[pageIndex] || []; return currentQuestions.every((q) => !!answers[q.id]); }; const handleNext = () => { if (!currentPageAllAnswered()) { setPageError('このページの設問すべてにお答えください'); return; } setPageError(null); setPageIndex((idx) => Math.min(idx + 1, totalPages - 1)); scrollToTop(); }; const handlePrev = () => { setPageError(null); setPageIndex((idx) => Math.max(idx - 1, 0)); scrollToTop(); }; const handleSubmit = (event) => { event.preventDefault(); if (submitting || !questions || !axes) return; if (!currentPageAllAnswered()) { setPageError('このページの設問すべてにお答えください'); return; } const unanswered = questions.some((q) => !answers[q.id]); if (unanswered) { setPageError('全ての設問にお答えください'); return; } const code = calculateTypeCode(answers, questions, axes); if (mode === 'update') { setSubmitting(true); setSubmitError(null); fetch(submitUrl, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': csrfToken() }, credentials: 'same-origin', body: JSON.stringify({ type_code: code }) }) .then(async (res) => { const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (data.redirect_to) { window.location.assign(data.redirect_to); } else { window.location.reload(); } }) .catch((err) => { setSubmitting(false); setSubmitError(err.message || '更新に失敗しました'); }); } else { window.location.assign(`${redirectBase}${code}`); } }; if (loadError) { return (
質問の読み込みに失敗しました: {loadError}
); } if (!questions || !axes) { return (
読み込み中...
); } const currentQuestions = pages[pageIndex] || []; const baseQuestionNumber = pageIndex * PAGE_SIZE; return (
{/* プログレス */}
PAGE {pageIndex + 1} / {totalPages} {answeredCount} / {totalCount} ({progressPercent}%)
{currentQuestions.map((question, indexOnPage) => { const number = baseQuestionNumber + indexOnPage + 1; const numberLabel = `Q.${String(number).padStart(2, '0')}`; return (
{numberLabel}
{numberLabel}

{question.text}

あてはまらない
{CHOICES.map((choice) => { const inputId = `q-${question.id}-${choice.value}`; const checked = answers[question.id] === choice.value; return ( handleChange(question.id, choice.value)} autoComplete="off" /> ); })}
あてはまる
); })}
{pageError && (
{pageError}
)} {submitError && (
{submitError}
)}
{isLastPage ? ( ) : ( )}
); }; const initializeDiagnosisForms = () => { document.querySelectorAll('[data-diagnosis-form]').forEach((el) => { if (el.dataset.diagnosisFormMounted === 'true') return; el.dataset.diagnosisFormMounted = 'true'; try { const root = createRoot(el); root.render(); } catch (error) { console.error('Error rendering DiagnosisForm:', error); } }); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeDiagnosisForms); } else { initializeDiagnosisForms(); }