fix: auto-create subjects for review registration

This commit is contained in:
reloop
2026-04-19 17:52:17 +09:00
parent 132a7e397a
commit eca67cfd07
3 changed files with 190 additions and 49 deletions

View File

@@ -6,7 +6,9 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import styled, { css, keyframes } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import TagPrompt from '@/components/ui/TagPrompt';
import { Icon } from '@/components/ui/Icon';
import { useToast } from '@/components/ui/Toast';
import { Badge, Button, Card } from '@/components/ui/primitives';
import { theme } from '@/styles/theme';
import { api, type Subject, type StudyLog } from '@/lib/api';
@@ -257,6 +259,8 @@ interface EbookViewerProps {
function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
const storageKey = `reloop-ebook-${id}`;
const { showToast } = useToast();
const recommendedSubjectName = grade;
const [pageIndex, setPageIndex] = useState<number>(() => {
if (typeof window === 'undefined') return 0;
@@ -290,10 +294,12 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
const [clearSignal, setClearSignal] = useState(0);
// 자기 평가 관련 state
const [mathSubjectId, setMathSubjectId] = useState<number | null | undefined>(undefined);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedSubjectId, setSelectedSubjectId] = useState<number | ''>('');
const [registeredProblems, setRegisteredProblems] = useState<Set<number>>(new Set());
const [registeringProblem, setRegisteringProblem] = useState<number | null>(null);
const [registeredDates, setRegisteredDates] = useState<Record<number, Date>>({});
const [tagPromptFor, setTagPromptFor] = useState<{ studyLogId: number; subjectId: number } | null>(null);
const total = problems.length;
const problem = problems[pageIndex];
@@ -311,15 +317,20 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
localStorage.setItem(`${storageKey}-page`, String(pageIndex));
}, [storageKey, pageIndex]);
// 마운트 시 수학 subject ID 조회
// 마운트 시 과목 조회 및 추천 과목 선택
useEffect(() => {
api.get<Subject[]>('/subjects').then((res) => {
const math = res.data.find((s) => s.name === '수학');
setMathSubjectId(math?.id ?? null);
}).catch(() => {
setMathSubjectId(null);
});
}, []);
api
.get<Subject[]>('/subjects')
.then((res) => {
setSubjects(res.data);
const matched = res.data.find((subject) => subject.name === recommendedSubjectName);
setSelectedSubjectId(matched?.id ?? '');
})
.catch(() => {
setSubjects([]);
setSelectedSubjectId('');
});
}, [recommendedSubjectName]);
// 마운트 시 기존 복습 등록 상태 복원
useEffect(() => {
@@ -387,13 +398,26 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
const isRevealed = revealed[problem.number] ?? false;
const isCorrect = chosen !== undefined && chosen === problem.answer;
const ensureSubjectId = useCallback(async () => {
if (selectedSubjectId) return selectedSubjectId;
const created = await api.post<Subject>('/subjects', {
name: recommendedSubjectName,
color: '#6366f1',
});
setSubjects((prev) => [...prev, created.data]);
setSelectedSubjectId(created.data.id);
return created.data.id;
}, [recommendedSubjectName, selectedSubjectId]);
const handleSelfDifficulty = useCallback(async (sd: SelfDifficulty) => {
if (!mathSubjectId || registeredProblems.has(problem.number) || registeringProblem !== null) return;
if (registeredProblems.has(problem.number) || registeringProblem !== null) return;
setRegisteringProblem(problem.number);
try {
const res = await api.post<{ nextReview: { scheduledAt: string } }>('/study-logs', {
subjectId: mathSubjectId,
const subjectId = await ensureSubjectId();
const res = await api.post<{ studyLog: { id: number }; nextReview: { scheduledAt: string } }>('/study-logs', {
subjectId,
title: `[${grade}] #${problem.number} ${problem.question.slice(0, 40)}`,
difficulty: DIFFICULTY_SCORE[problem.difficulty],
result: isCorrect ? 'correct' : 'incorrect',
@@ -404,12 +428,13 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
const scheduledAt = new Date(res.data.nextReview.scheduledAt);
setRegisteredProblems((prev) => new Set(prev).add(problem.number));
setRegisteredDates((prev) => ({ ...prev, [problem.number]: scheduledAt }));
setTagPromptFor({ studyLogId: res.data.studyLog.id, subjectId });
} catch {
// 등록 실패 시 조용히 무시 (서버 오류 등)
showToast({ message: '복습 등록 실패했어.', variant: 'danger' });
} finally {
setRegisteringProblem(null);
}
}, [mathSubjectId, registeredProblems, registeringProblem, problem, grade, isCorrect, chosen]);
}, [ensureSubjectId, registeredProblems, registeringProblem, problem, grade, isCorrect, chosen, showToast]);
const handleReset = () => {
if (!window.confirm('모든 풀이 기록을 초기화할까? 복습 등록은 유지돼.')) return;
@@ -584,14 +609,9 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
)}
{/* 자기 평가 UI */}
{isRevealed && mathSubjectId !== undefined && (
{isRevealed && (
<SelfEvalBox>
{mathSubjectId === null ? (
<SelfEvalNoSubject>
<Icon name="info" size={14} />
&apos;&apos; .
</SelfEvalNoSubject>
) : alreadyRegistered ? (
{alreadyRegistered ? (
<SelfEvalRegistered>
<Icon name="check-circle" size={15} />
!{' '}
@@ -603,6 +623,24 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
</SelfEvalRegistered>
) : (
<>
<SubjectAssistRow>
<SubjectAssistLabel> </SubjectAssistLabel>
<SubjectAssistSelect
value={selectedSubjectId}
onChange={(event) =>
setSelectedSubjectId(
event.target.value === '' ? '' : Number(event.target.value),
)
}
>
<option value=""> : {recommendedSubjectName}</option>
{subjects.map((subject) => (
<option key={subject.id} value={subject.id}>
{subject.name}
</option>
))}
</SubjectAssistSelect>
</SubjectAssistRow>
<SelfEvalTitle> ?</SelfEvalTitle>
<SelfEvalButtons>
<SelfEvalButton
@@ -704,6 +742,15 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
</InfoDesc>
</InfoContent>
</InfoBox>
{tagPromptFor && (
<TagPrompt
studyLogId={tagPromptFor.studyLogId}
preferredSubjectId={tagPromptFor.subjectId}
onClose={() => setTagPromptFor(null)}
onSaved={() => setTagPromptFor(null)}
/>
)}
</ViewerWrap>
);
}
@@ -1192,12 +1239,30 @@ const SelfEvalRegistered = styled.div`
color: ${theme.color.success};
`;
const SelfEvalNoSubject = styled.div`
const SubjectAssistRow = styled.div`
display: flex;
align-items: center;
gap: 6px;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
`;
const SubjectAssistLabel = styled.span`
font-size: 12px;
color: ${theme.color.textMute};
font-weight: 700;
color: ${theme.color.textSub};
`;
const SubjectAssistSelect = styled.select`
min-width: 180px;
height: 34px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255, 255, 255, 0.04);
color: ${theme.color.textBright};
font-size: 12px;
outline: none;
`;
// ── 네비게이션 스타일 ───────────────────────────────────────────────────────

View File

@@ -63,11 +63,12 @@ function UploadedEbookBody() {
const [memoOpen, setMemoOpen] = useState(false);
// 자기 평가
const [mathSubjectId, setMathSubjectId] = useState<number | null | undefined>(undefined);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedSubjectId, setSelectedSubjectId] = useState<number | ''>('');
const [registeredProblems, setRegisteredProblems] = useState<Set<number>>(new Set());
const [registeringProblem, setRegisteringProblem] = useState<number | null>(null);
const [registeredDates, setRegisteredDates] = useState<Record<number, Date>>({});
const [tagPromptFor, setTagPromptFor] = useState<number | null>(null);
const [tagPromptFor, setTagPromptFor] = useState<{ studyLogId: number; subjectId: number } | null>(null);
useEffect(() => {
if (!Number.isFinite(id)) {
@@ -94,13 +95,21 @@ function UploadedEbookBody() {
.finally(() => setLoading(false));
}, [id]);
// 수학 subject ID 조회
// 과목 조회 및 기본 subject 선택
useEffect(() => {
api.get<Subject[]>('/subjects').then((res) => {
const math = res.data.find((s) => s.name === '수학');
setMathSubjectId(math?.id ?? null);
}).catch(() => setMathSubjectId(null));
}, []);
api
.get<Subject[]>('/subjects')
.then((res) => {
setSubjects(res.data);
const preferredName = problemSet?.subjectName?.trim() || '수학';
const matched = res.data.find((subject) => subject.name === preferredName);
setSelectedSubjectId(matched?.id ?? '');
})
.catch(() => {
setSubjects([]);
setSelectedSubjectId('');
});
}, [problemSet?.subjectName]);
// 기존 복습 등록 상태 복원
useEffect(() => {
@@ -163,15 +172,29 @@ function UploadedEbookBody() {
return () => window.removeEventListener('keydown', handler);
}, [pageIndex, goTo]);
const ensureSubjectId = useCallback(async () => {
if (selectedSubjectId) return selectedSubjectId;
const subjectName = problemSet?.subjectName?.trim() || '수학';
const created = await api.post<Subject>('/subjects', {
name: subjectName,
color: '#6366f1',
});
setSubjects((prev) => [...prev, created.data]);
setSelectedSubjectId(created.data.id);
return created.data.id;
}, [problemSet?.subjectName, selectedSubjectId]);
const handleSelfDifficulty = useCallback(async (sd: SelfDifficulty) => {
if (!mathSubjectId || !problem || registeredProblems.has(problem.number) || registeringProblem !== null) return;
if (!problem || registeredProblems.has(problem.number) || registeringProblem !== null) return;
setRegisteringProblem(problem.number);
try {
const subjectId = await ensureSubjectId();
const chosen = selectedAnswers[problem.number];
const isCorrect = chosen !== undefined && problem.answerNumber !== null && chosen === problem.answerNumber;
const res = await api.post<{ studyLog: { id: number }; nextReview: { scheduledAt: string } }>('/study-logs', {
subjectId: mathSubjectId,
subjectId,
problemId: problem.id,
title: `${problemSet!.title} ${problem.number}`,
difficulty: 0.5,
@@ -185,13 +208,13 @@ function UploadedEbookBody() {
const createdStudyLogId = res.data.studyLog.id;
setRegisteredProblems((prev) => new Set(prev).add(problem.number));
setRegisteredDates((prev) => ({ ...prev, [problem.number]: scheduledAt }));
setTagPromptFor(createdStudyLogId);
setTagPromptFor({ studyLogId: createdStudyLogId, subjectId });
} catch {
showToast({ message: '복습 등록에 실패했어.', variant: 'danger' });
} finally {
setRegisteringProblem(null);
}
}, [mathSubjectId, problem, problemSet, registeredProblems, registeringProblem, selectedAnswers, memos, showToast]);
}, [ensureSubjectId, problem, problemSet, registeredProblems, registeringProblem, selectedAnswers, memos, showToast]);
if (loading) return <StateBox> ...</StateBox>;
if (error || !problemSet || !problem) {
@@ -314,11 +337,9 @@ function UploadedEbookBody() {
</MemoSection>
{/* 자기 평가 */}
{isRevealed && mathSubjectId !== undefined && (
{isRevealed && (
<EvalSection>
{mathSubjectId === null ? (
<EvalNote><Icon name="info" size={14} /> &apos;&apos; .</EvalNote>
) : alreadyRegistered ? (
{alreadyRegistered ? (
<EvalDone>
<Icon name="check-circle" size={15} />
!{' '}
@@ -326,6 +347,26 @@ function UploadedEbookBody() {
</EvalDone>
) : (
<>
<EvalSubjectRow>
<EvalSubjectLabel> </EvalSubjectLabel>
<EvalSubjectSelect
value={selectedSubjectId}
onChange={(event) =>
setSelectedSubjectId(
event.target.value === '' ? '' : Number(event.target.value),
)
}
>
<option value="">
: {problemSet.subjectName?.trim() || '수학'}
</option>
{subjects.map((subject) => (
<option key={subject.id} value={subject.id}>
{subject.name}
</option>
))}
</EvalSubjectSelect>
</EvalSubjectRow>
<EvalTitle> ?</EvalTitle>
<EvalBtns>
{(['hard', 'medium', 'easy'] as SelfDifficulty[]).map((sd) => (
@@ -384,7 +425,8 @@ function UploadedEbookBody() {
{tagPromptFor && (
<TagPrompt
studyLogId={tagPromptFor}
studyLogId={tagPromptFor.studyLogId}
preferredSubjectId={tagPromptFor.subjectId}
onClose={() => setTagPromptFor(null)}
onSaved={() => setTagPromptFor(null)}
/>
@@ -575,9 +617,30 @@ const EvalSection = styled.div`
background: rgba(255,255,255,0.02);
`;
const EvalNote = styled.div`
display: flex; align-items: center; gap: 8px;
color: ${theme.color.textSub}; font-size: 13px;
const EvalSubjectRow = styled.div`
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
`;
const EvalSubjectLabel = styled.span`
color: ${theme.color.textSub};
font-size: 12px;
font-weight: 700;
`;
const EvalSubjectSelect = styled.select`
min-width: 180px;
height: 34px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(255,255,255,0.04);
color: ${theme.color.textBright};
font-size: 13px;
outline: none;
`;
const EvalDone = styled.div`

View File

@@ -11,13 +11,21 @@ interface TagPromptProps {
studyLogId: number;
/** 현재 이미 할당된 tagId (있으면 프롬프트 안 띄움) */
currentTagId?: number | null;
/** 기본 선택할 subject */
preferredSubjectId?: number | null;
/** 닫기 */
onClose: () => void;
/** 태그 저장 성공 */
onSaved?: (tag: { id: number; name: string }) => void;
}
export default function TagPrompt({ studyLogId, currentTagId, onClose, onSaved }: TagPromptProps) {
export default function TagPrompt({
studyLogId,
currentTagId,
preferredSubjectId,
onClose,
onSaved,
}: TagPromptProps) {
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedSubjectId, setSelectedSubjectId] = useState<number | ''>('');
const [tags, setTags] = useState<Tag[]>([]);
@@ -28,12 +36,17 @@ export default function TagPrompt({ studyLogId, currentTagId, onClose, onSaved }
useEffect(() => {
api.get<Subject[]>('/subjects').then((r) => {
setSubjects(r.data);
if (r.data[0]) {
setSelectedSubjectId(r.data[0].id);
setTags(r.data[0].tags ?? []);
const preferred =
preferredSubjectId !== null && preferredSubjectId !== undefined
? r.data.find((subject) => subject.id === preferredSubjectId)
: null;
const initialSubject = preferred ?? r.data[0];
if (initialSubject) {
setSelectedSubjectId(initialSubject.id);
setTags(initialSubject.tags ?? []);
}
}).catch(() => {});
}, []);
}, [preferredSubjectId]);
const handleSubjectChange = (subjectId: number) => {
setSelectedSubjectId(subjectId);