/** * 홈 — 설문·검사 시작/진행률, 오늘 완료 여부 (PRD §3.6.1) * Phase D: MD3 디자인 토큰 + 공용 컴포넌트(PillButton, StatusBadge, InfoBanner) 적용 */ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { http } from '../lib/api'; import { useAuthStore } from '../store/auth'; import { getSurveyKey } from '../lib/storage'; import { SURVEY_CATEGORIES, SURVEY_CATEGORY_TOTAL, } from '../data/surveys'; import PillButton from '../components/PillButton'; import StatusBadge from '../components/StatusBadge'; import InfoBanner from '../components/InfoBanner'; interface StatusResp { todayCompleted: boolean; inProgress: boolean; round: number | null; status: string | null; done: Record; } interface CurrentResp { history: { id: number; round: number; status: string } | null; cntData: Record | null; } type SurveyAnswer = { value?: string; happy?: number; sad?: number; angry?: number }; type SurveyAnswers = Record; const Container = styled.div` padding-top: 16px; `; const Greeting = styled.h2` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 22px; font-weight: 700; letter-spacing: -0.01em; color: var(--md3-on-surface); margin: 0 0 4px; `; const Sub = styled.p` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 14px; color: var(--md3-on-surface-variant); margin: 0 0 16px; `; const Card = styled.div` background: var(--md3-surface); border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); border: 1px solid var(--md3-outline-variant); `; const SectionTitle = styled.div` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 13px; font-weight: 600; color: var(--md3-on-surface-variant); margin-bottom: 16px; `; const Row = styled.div` display: flex; align-items: center; justify-content: space-between; padding: 14px 0; border-bottom: 1px solid var(--md3-outline-variant); &:last-child { border-bottom: none; } .label { font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 15px; color: var(--md3-on-surface); } `; const DoneBadge = styled(StatusBadge)` && { background: var(--md3-tertiary-container) !important; color: var(--md3-on-tertiary-container) !important; border: 1px solid var(--md3-tertiary) !important; } `; const SubList = styled.div` padding: 8px 0 16px 8px; display: flex; flex-direction: column; gap: 8px; `; const SubRow = styled.div` display: flex; justify-content: space-between; align-items: center; font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 13px; color: var(--md3-on-surface-variant); .num { width: 24px; color: var(--md3-outline); font-size: 12px; } .name { flex: 1; min-width: 0; } `; const SubStatus = styled.span<{ $done?: boolean; $pct?: number }>` font-weight: 600; color: ${(p) => (p.$done ? 'var(--md3-tertiary)' : p.$pct && p.$pct > 0 ? 'var(--md3-primary)' : 'var(--md3-on-surface-variant)')}; `; const CompletedNotice = styled(InfoBanner)` && { margin-bottom: 16px; } `; const ButtonGroup = styled.div` display: flex; flex-direction: column; gap: 12px; `; const SECTIONS = [ { key: 'survey', label: '정서 설문 7종' }, { key: 'MC', label: '소근육 탭핑 (MC)' }, { key: 'EP', label: '얼굴표정 (EP)' }, { key: 'CST', label: '카드정렬 (CST)' }, { key: 'NBACK', label: 'N-Back (NB)' }, { key: 'MR', label: '회전도형 (MR)' }, ]; const SURVEY_KEYS = ['PHQ', 'OCI', 'KAQ', 'GAD', 'KSPIN', 'ASRS', 'EPS']; /** 프론트 카테고리 키 → 백엔드 필드명 */ const BACKEND_CAT_KEY: Record = { phq: 'PHQ', oci: 'OCI', kaq: 'KAQ', gad7: 'GAD', kspin: 'KSPIN', asrs: 'ASRS', ep: 'EPS', }; function backendCatKey(k: string) { return BACKEND_CAT_KEY[k] ?? k.toUpperCase(); } function isAnswered(a: SurveyAnswer | undefined): boolean { if (!a) return false; if (a.value) return true; return (a.happy ?? 0) > 0 || (a.sad ?? 0) > 0 || (a.angry ?? 0) > 0; } export default function Home() { const nav = useNavigate(); const user = useAuthStore((s) => s.user); const [status, setStatus] = useState(null); const [currentData, setCurrentData] = useState(null); const [localAnswers, setLocalAnswers] = useState({}); useEffect(() => { http.get('/api/cntdata/status').then(setStatus).catch(() => {}); http.get('/api/cntdata/current').then(setCurrentData).catch(() => {}); if (user) { try { const raw = localStorage.getItem(getSurveyKey(user.id)); setLocalAnswers(raw ? JSON.parse(raw) : {}); } catch { setLocalAnswers({}); } } }, [user]); /** 카테고리별로 답변된 항목 수 계산 (value 또는 슬라이더 1개 이상) */ const answeredCountByCat = useMemo(() => { const map: Record = {}; SURVEY_CATEGORIES.forEach((c) => { const list = localAnswers[c.key] ?? []; map[c.key] = list.filter((a) => isAnswered(a)).length; }); return map; }, [localAnswers]); /** 설문 카테고리별 백분률 * 수정사항-037: 백엔드 완료 상태만 사용 (기기 간 localStorage 불일치 방지) * - 완료: 100% * - 미완료: 현재 백엔드에 데이터가 없으므로 0% */ const surveyPctByCat = useMemo(() => { const map: Record = {}; SURVEY_CATEGORIES.forEach((c) => { const done = !!currentData?.cntData?.[backendCatKey(c.key)]; map[c.key] = done ? 100 : 0; }); return map; }, [currentData]); /** 설문 전체 진행률 (가중치 적용: 12필드 중 7필드 = 설문 7문항 합) */ const surveyTotalItems = SURVEY_CATEGORIES.reduce( (sum, c) => sum + (SURVEY_CATEGORY_TOTAL[c.key] || 0), 0, ); /** 설문 7종 전체 완료 여부 (cntData 기준 — staleness 무시) */ const surveyDone = SURVEY_KEYS.every((k) => !!currentData?.cntData?.[k]); // 백엔드 완료 상태 우선, localStorage는 보조 (cntData 기준 — staleness 무시) const totalAnswered = surveyDone ? surveyTotalItems // 완료라고 하면 100% : SURVEY_CATEGORIES.reduce( (sum, c) => { const backendDone = !!currentData?.cntData?.[backendCatKey(c.key)]; if (backendDone) { // 완료된 카테고리는 전체 개수로 계산 return sum + (SURVEY_CATEGORY_TOTAL[c.key] || 0); } // 미완료 카테고리는 localStorage 기준 return sum + Math.min(answeredCountByCat[c.key] || 0, SURVEY_CATEGORY_TOTAL[c.key] || 0); }, 0, ); const surveyPct = Math.round((totalAnswered / surveyTotalItems) * 100); /** 12개 전체 진행률 (완료 필드 수 + 설문 부분 진행 반영) * 설문 7종은 cntData 기준(staleness 무시), 인지검사 5종은 status.done 기준 */ const cntDoneCount = status ? Object.entries(status.done) .filter(([k, v]) => v && ['MC', 'EP', 'CST', 'NBACK', 'MR'].includes(k)) .length : 0; const surveyCompleteCount = surveyDone ? 7 : 0; const completedFields = surveyCompleteCount + cntDoneCount; const surveyPartialContribution = surveyDone ? 0 : (totalAnswered / surveyTotalItems) * 7; // 설문 7필드의 부분 진행 const overallPct = status ? Math.round(((completedFields + surveyPartialContribution) / 12) * 100) : 0; return ( {user?.nickname ?? user?.name}님, 안녕하세요 나의 인지건강을 스스로 체크해보세요. {status?.todayCompleted && ( <> 오늘은 이미 검사를 완료하셨습니다. 내일 다시 시도해주세요. nav('/dashboard')}> 결과 대시보드 보기 )} {!status?.todayCompleted && ( <> 진행 상황 {status ? `${overallPct}%` : ''} {SECTIONS.map((s) => { if (s.key === 'survey') { const isDone = surveyDone; const label = surveyDone ? '완료 ✓' : surveyPct > 0 ? `${surveyPct}% 진행` : '미완료'; return (
{s.label} {isDone ? ( 완료 ) : ( {label} )} {SURVEY_CATEGORIES.map((c, i) => { const done = !!currentData?.cntData?.[backendCatKey(c.key)]; const pct = surveyPctByCat[c.key] ?? 0; return ( {i + 1} {c.title} {done ? '완료 ✓' : pct > 0 ? `${pct}% 진행` : '미완료'} ); })}
); } const isDone = !!status?.done?.[s.key]; return ( {s.label} {isDone ? ( 완료 ) : ( 미완료 )} ); })}
{surveyDone ? ( <> nav('/test')}> 인지검사 시작하기 nav('/survey')}> 설문 완료 ✓ (내일 다시 시작 가능) ) : ( <> nav('/survey')}> {surveyPct > 0 ? '설문 이어서 하기' : '정서 설문 시작하기'} 설문 완료 후 검사가 열립니다 )} )}
); }