feat(study): 시험·회차·문항 관리 (PR-6)

기사시험 회차별 100문제 채워가기 시나리오. 문제 입력 페이지를 단순 폼에서
"회차 진행률 추적·재개" 도구로 보강.

데이터 모델 (migrations 195~197):
- study_topics: exam_round_size INT CHECK 1~300 (회차당 문항 수, NULL=미설정)
  + exam_subjects JSONB DEFAULT '[]' (과목 리스트, 입력 페이지 드롭다운 옵션)
- study_questions: exam_question_number SMALLINT CHECK >0 (회차 안 문항 번호)
- partial idx (study_topic_id, exam_round, exam_question_number) WHERE
  deleted_at IS NULL AND exam_round IS NOT NULL — 회차별 max+count 고속화

백엔드:
- POST /questions: exam_round 명시 + exam_question_number 미명시 시 서버가
  같은 토픽·회차의 max+1 자동 채움
- 신규 GET /api/study-topics/{id}/exam-rounds: 회차별 진행률 집계
  {exam_round_size, items: [{exam_round, question_count, max_question_number,
   next_question_number, is_complete}]}
- StudyTopic Create/Update/Response/Meta 에 exam_round_size·exam_subjects
- StudyQuestion Create/Update/Response 에 exam_question_number
- exam_question_number 변경은 embedding stale 트리거에서 제외 (의미 영향 없음)

프론트:
- 토픽 생성/편집 모달: "시험 정보" 섹션 (회차당 문항 수 + 과목 리스트
  +추가/제거 칩)
- /study/topics/[id]/exam-rounds 신규 페이지: 회차 카드 + 진행 바 +
  [N번부터 이어서] 버튼 + [새 회차 시작] 모달
- 통합뷰 문제 섹션 헤더에 [회차 보기] 진입점
- /questions/new 페이지 전면 개편:
  - 시험명 = topic.name 자동 prefill
  - 과목 드롭다운 (topic.exam_subjects + 기존 distinct, "직접 입력" 토글)
  - 회차 드롭다운 (기존 distinct + "새 회차")
  - 문항 번호 자동 (회차 선택 시 next_question_number, 새 회차 = 1)
  - 진행률 바 (현재/exam_round_size)
  - 출처/메모 자동 합성 "회차 N번" (수정 가능)
  - "저장 후 계속 입력" → 본문/보기/정답 reset, 회차 유지, 문항 +1
  - 회차 변경 감지 시 문항 번호 1로 reset
  - exam_round_size 도달 시 회차 강조 + "저장 후 계속 입력" 비활성
- query string ?exam_round=&start_qnum= 지원 (회차 목록에서 재개 진입)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 09:31:06 +09:00
parent 5b55274368
commit 8803e6a0fd
11 changed files with 719 additions and 62 deletions
+26
View File
@@ -67,6 +67,8 @@ class StudyQuestionCreate(BaseModel):
scope: str | None = Field(default=None, max_length=200) scope: str | None = Field(default=None, max_length=200)
exam_name: str | None = Field(default=None, max_length=120) exam_name: str | None = Field(default=None, max_length=120)
exam_round: str | None = Field(default=None, max_length=120) exam_round: str | None = Field(default=None, max_length=120)
# PR-6: 회차 안 문항 번호. 미명시 + exam_round 명시 시 서버가 max+1 자동 채움.
exam_question_number: int | None = Field(default=None, ge=1)
explanation: str | None = None explanation: str | None = None
source_note: str | None = None source_note: str | None = None
is_active: bool = True is_active: bool = True
@@ -83,6 +85,7 @@ class StudyQuestionUpdate(BaseModel):
scope: str | None = Field(default=None, max_length=200) scope: str | None = Field(default=None, max_length=200)
exam_name: str | None = Field(default=None, max_length=120) exam_name: str | None = Field(default=None, max_length=120)
exam_round: str | None = Field(default=None, max_length=120) exam_round: str | None = Field(default=None, max_length=120)
exam_question_number: int | None = Field(default=None, ge=1)
explanation: str | None = None explanation: str | None = None
source_note: str | None = None source_note: str | None = None
is_active: bool | None = None is_active: bool | None = None
@@ -112,6 +115,8 @@ class StudyQuestionResponse(BaseModel):
explanation: str | None explanation: str | None
source_note: str | None source_note: str | None
is_active: bool is_active: bool
# PR-6: 회차 안 문항 번호
exam_question_number: int | None = None
# PR-3: AI 풀이 상태 (편집 화면에서 사용). 본문은 별도 GET /ai-explanation 으로 # PR-3: AI 풀이 상태 (편집 화면에서 사용). 본문은 별도 GET /ai-explanation 으로
ai_explanation_status: str = "none" ai_explanation_status: str = "none"
ai_explanation_generated_at: datetime | None = None ai_explanation_generated_at: datetime | None = None
@@ -378,6 +383,22 @@ async def create_question_in_topic(
topic = await session.get(StudyTopic, topic_id) topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user) _verify_topic_ownership(topic, user)
# PR-6: exam_question_number 미명시 + exam_round 명시 시 서버가 max+1 자동 채움.
# 개인 사용 환경 race 부담 적어 단순 SELECT max 사용. 동시 입력 빈번해지면 향후
# SELECT FOR UPDATE 또는 토픽 단위 advisory lock 으로 강화 검토.
qnum = body.exam_question_number
if qnum is None and body.exam_round:
max_row = await session.execute(
select(func.coalesce(func.max(StudyQuestion.exam_question_number), 0))
.where(
StudyQuestion.user_id == user.id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.exam_round == body.exam_round,
StudyQuestion.deleted_at.is_(None),
)
)
qnum = int(max_row.scalar() or 0) + 1
q = StudyQuestion( q = StudyQuestion(
user_id=user.id, user_id=user.id,
study_topic_id=topic_id, study_topic_id=topic_id,
@@ -391,6 +412,7 @@ async def create_question_in_topic(
scope=body.scope, scope=body.scope,
exam_name=body.exam_name, exam_name=body.exam_name,
exam_round=body.exam_round, exam_round=body.exam_round,
exam_question_number=qnum,
explanation=body.explanation, explanation=body.explanation,
source_note=body.source_note, source_note=body.source_note,
is_active=body.is_active, is_active=body.is_active,
@@ -416,6 +438,7 @@ async def create_question_in_topic(
explanation=q.explanation, explanation=q.explanation,
source_note=q.source_note, source_note=q.source_note,
is_active=q.is_active, is_active=q.is_active,
exam_question_number=q.exam_question_number,
ai_explanation_status=q.ai_explanation_status, ai_explanation_status=q.ai_explanation_status,
ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_generated_at=q.ai_explanation_generated_at,
ai_explanation_model=q.ai_explanation_model, ai_explanation_model=q.ai_explanation_model,
@@ -610,6 +633,7 @@ async def get_question(
explanation=q.explanation, explanation=q.explanation,
source_note=q.source_note, source_note=q.source_note,
is_active=q.is_active, is_active=q.is_active,
exam_question_number=q.exam_question_number,
ai_explanation_status=q.ai_explanation_status, ai_explanation_status=q.ai_explanation_status,
ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_generated_at=q.ai_explanation_generated_at,
ai_explanation_model=q.ai_explanation_model, ai_explanation_model=q.ai_explanation_model,
@@ -635,6 +659,7 @@ async def update_question(
SIMPLE_FIELDS = { SIMPLE_FIELDS = {
"question_text", "choice_1", "choice_2", "choice_3", "choice_4", "question_text", "choice_1", "choice_2", "choice_3", "choice_4",
"correct_choice", "subject", "scope", "exam_name", "exam_round", "correct_choice", "subject", "scope", "exam_name", "exam_round",
"exam_question_number",
"explanation", "source_note", "is_active", "explanation", "source_note", "is_active",
} }
for fname in SIMPLE_FIELDS & fields_set: for fname in SIMPLE_FIELDS & fields_set:
@@ -676,6 +701,7 @@ async def update_question(
explanation=q.explanation, explanation=q.explanation,
source_note=q.source_note, source_note=q.source_note,
is_active=q.is_active, is_active=q.is_active,
exam_question_number=q.exam_question_number,
ai_explanation_status=q.ai_explanation_status, ai_explanation_status=q.ai_explanation_status,
ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_generated_at=q.ai_explanation_generated_at,
ai_explanation_model=q.ai_explanation_model, ai_explanation_model=q.ai_explanation_model,
+94
View File
@@ -53,6 +53,9 @@ class StudyTopicCreate(BaseModel):
color: str | None = Field(default=None, max_length=20) color: str | None = Field(default=None, max_length=20)
study_type: str | None = Field(default=None, max_length=40) study_type: str | None = Field(default=None, max_length=40)
sort_order: int = 0 sort_order: int = 0
# PR-6: 시험 메타
exam_round_size: int | None = Field(default=None, ge=1, le=300)
exam_subjects: list[str] = []
class StudyTopicUpdate(BaseModel): class StudyTopicUpdate(BaseModel):
@@ -61,6 +64,9 @@ class StudyTopicUpdate(BaseModel):
color: str | None = Field(default=None, max_length=20) color: str | None = Field(default=None, max_length=20)
study_type: str | None = Field(default=None, max_length=40) study_type: str | None = Field(default=None, max_length=40)
sort_order: int | None = None sort_order: int | None = None
# PR-6: 시험 메타
exam_round_size: int | None = Field(default=None, ge=1, le=300)
exam_subjects: list[str] | None = None
class StudyTopicResponse(BaseModel): class StudyTopicResponse(BaseModel):
@@ -75,6 +81,9 @@ class StudyTopicResponse(BaseModel):
session_count: int = 0 session_count: int = 0
document_count: int = 0 document_count: int = 0
question_count: int = 0 question_count: int = 0
# PR-6: 시험 메타
exam_round_size: int | None = None
exam_subjects: list[str] = []
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -165,6 +174,9 @@ class StudyTopicMeta(BaseModel):
color: str | None color: str | None
study_type: str | None study_type: str | None
sort_order: int sort_order: int
# PR-6: 시험 메타
exam_round_size: int | None = None
exam_subjects: list[str] = []
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -218,6 +230,8 @@ def _meta_from_topic(t: StudyTopic) -> StudyTopicMeta:
color=t.color, color=t.color,
study_type=t.study_type, study_type=t.study_type,
sort_order=t.sort_order, sort_order=t.sort_order,
exam_round_size=t.exam_round_size,
exam_subjects=t.exam_subjects or [],
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
) )
@@ -303,6 +317,8 @@ async def list_study_topics(
session_count=int(sc), session_count=int(sc),
document_count=int(dc), document_count=int(dc),
question_count=int(qc), question_count=int(qc),
exam_round_size=t.exam_round_size,
exam_subjects=t.exam_subjects or [],
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
) )
@@ -311,6 +327,72 @@ async def list_study_topics(
return StudyTopicListResponse(items=items, total=total) return StudyTopicListResponse(items=items, total=total)
class ExamRoundProgress(BaseModel):
exam_round: str
question_count: int
max_question_number: int # 0 = exam_question_number 가 모두 NULL
next_question_number: int | None # 도달 시 None
is_complete: bool
class ExamRoundsResponse(BaseModel):
exam_round_size: int | None # 토픽 메타. NULL = 미설정
items: list[ExamRoundProgress]
@router.get("/{topic_id}/exam-rounds", response_model=ExamRoundsResponse)
async def list_exam_rounds(
topic_id: int,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""토픽 안 회차별 진행률 집계. 진행률 표시 + 재개 UX 용.
is_complete = (exam_round_size IS NOT NULL AND question_count >= exam_round_size).
next_question_number = max + 1 (도달 시 NULL).
"""
topic = await session.get(StudyTopic, topic_id)
_verify_topic_ownership(topic, user)
from models.study_question import StudyQuestion as _SQ
rows = (
await session.execute(
select(
_SQ.exam_round,
func.count().label("question_count"),
func.coalesce(func.max(_SQ.exam_question_number), 0).label("max_qnum"),
)
.where(
_SQ.user_id == user.id,
_SQ.study_topic_id == topic_id,
_SQ.deleted_at.is_(None),
_SQ.exam_round.is_not(None),
)
.group_by(_SQ.exam_round)
.order_by(_SQ.exam_round.desc())
)
).all()
size = topic.exam_round_size
items: list[ExamRoundProgress] = []
for r in rows:
cnt = int(r.question_count)
mx = int(r.max_qnum)
is_complete = bool(size is not None and cnt >= size)
# 도달했으면 next None, 아니면 max+1 (max=0 이면 1번부터)
next_n = None if is_complete else (mx + 1 if mx > 0 else 1)
items.append(ExamRoundProgress(
exam_round=r.exam_round,
question_count=cnt,
max_question_number=mx,
next_question_number=next_n,
is_complete=is_complete,
))
return ExamRoundsResponse(exam_round_size=size, items=items)
@router.get("/by-document/{document_id}", response_model=list[StudyTopicMeta]) @router.get("/by-document/{document_id}", response_model=list[StudyTopicMeta])
async def list_topics_for_document( async def list_topics_for_document(
document_id: int, document_id: int,
@@ -351,6 +433,8 @@ async def create_study_topic(
color=body.color, color=body.color,
study_type=body.study_type, study_type=body.study_type,
sort_order=body.sort_order, sort_order=body.sort_order,
exam_round_size=body.exam_round_size,
exam_subjects=body.exam_subjects or [],
) )
session.add(topic) session.add(topic)
try: try:
@@ -369,6 +453,9 @@ async def create_study_topic(
sort_order=topic.sort_order, sort_order=topic.sort_order,
session_count=0, session_count=0,
document_count=0, document_count=0,
question_count=0,
exam_round_size=topic.exam_round_size,
exam_subjects=topic.exam_subjects or [],
created_at=topic.created_at, created_at=topic.created_at,
updated_at=topic.updated_at, updated_at=topic.updated_at,
) )
@@ -570,6 +657,11 @@ async def update_study_topic(
topic.name = body.name.strip() topic.name = body.name.strip()
for fname in {"description", "color", "study_type", "sort_order"} & fields_set: for fname in {"description", "color", "study_type", "sort_order"} & fields_set:
setattr(topic, fname, getattr(body, fname)) setattr(topic, fname, getattr(body, fname))
# PR-6: 시험 메타. exam_subjects 가 None 이면 (PATCH 미명시) 기존 유지, 빈 배열은 명시적 클리어
if "exam_round_size" in fields_set:
topic.exam_round_size = body.exam_round_size
if "exam_subjects" in fields_set and body.exam_subjects is not None:
topic.exam_subjects = body.exam_subjects
topic.updated_at = datetime.now(timezone.utc) topic.updated_at = datetime.now(timezone.utc)
try: try:
@@ -610,6 +702,8 @@ async def update_study_topic(
session_count=int(sc), session_count=int(sc),
document_count=int(dc), document_count=int(dc),
question_count=int(qc), question_count=int(qc),
exam_round_size=topic.exam_round_size,
exam_subjects=topic.exam_subjects or [],
created_at=topic.created_at, created_at=topic.created_at,
updated_at=topic.updated_at, updated_at=topic.updated_at,
) )
+3
View File
@@ -43,6 +43,9 @@ class StudyQuestion(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# PR-6: 회차 안 문항 번호 (1~exam_round_size). NULL 허용 — 기존 행 + 회차 미설정 입력
exam_question_number: Mapped[int | None] = mapped_column(SmallInteger)
# PR-3: AI 풀이 캐시 (수동 트리거) # PR-3: AI 풀이 캐시 (수동 트리거)
# status: none | pending | ready | failed | stale (강한 enum 미사용, VARCHAR 권장값) # status: none | pending | ready | failed | stale (강한 enum 미사용, VARCHAR 권장값)
ai_explanation: Mapped[str | None] = mapped_column(Text) ai_explanation: Mapped[str | None] = mapped_column(Text)
+5
View File
@@ -18,6 +18,7 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.database import Base from core.database import Base
@@ -40,6 +41,10 @@ class StudyTopic(Base):
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# PR-6: 시험 메타 (회차당 문항 수 + 과목 리스트)
exam_round_size: Mapped[int | None] = mapped_column(Integer)
exam_subjects: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.now, nullable=False DateTime(timezone=True), default=datetime.now, nullable=False
) )
+107 -1
View File
@@ -41,11 +41,38 @@
let f_description = $state(''); let f_description = $state('');
let f_color = $state(''); let f_color = $state('');
let f_study_type = $state(''); let f_study_type = $state('');
// PR-6: 시험 메타
let f_exam_round_size = $state(''); // 빈 값 = 미설정
let f_exam_subjects = $state([]); // 문자열 배열
let f_subject_input = $state('');
let creating = $state(false); let creating = $state(false);
function addSubject() {
const v = f_subject_input.trim();
if (!v) return;
if (!f_exam_subjects.includes(v)) f_exam_subjects = [...f_exam_subjects, v];
f_subject_input = '';
}
function removeSubject(s) {
f_exam_subjects = f_exam_subjects.filter((x) => x !== s);
}
// 편집 모달 // 편집 모달
let editing = $state(null); // {id, name, description, color, study_type} let editing = $state(null); // {id, name, description, color, study_type, exam_round_size, exam_subjects}
let savingEdit = $state(false); let savingEdit = $state(false);
let edit_subject_input = $state('');
function addEditSubject() {
if (!editing) return;
const v = edit_subject_input.trim();
if (!v) return;
if (!editing.exam_subjects.includes(v)) editing.exam_subjects = [...editing.exam_subjects, v];
edit_subject_input = '';
}
function removeEditSubject(s) {
if (!editing) return;
editing.exam_subjects = editing.exam_subjects.filter((x) => x !== s);
}
async function load() { async function load() {
loading = true; loading = true;
@@ -74,6 +101,8 @@
description: f_description || null, description: f_description || null,
color: f_color || null, color: f_color || null,
study_type: f_study_type || null, study_type: f_study_type || null,
exam_round_size: f_exam_round_size ? Number(f_exam_round_size) : null,
exam_subjects: f_exam_subjects,
}; };
const t = await api('/study-topics/', { const t = await api('/study-topics/', {
method: 'POST', method: 'POST',
@@ -87,6 +116,9 @@
f_description = ''; f_description = '';
f_color = ''; f_color = '';
f_study_type = ''; f_study_type = '';
f_exam_round_size = '';
f_exam_subjects = [];
f_subject_input = '';
} catch (err) { } catch (err) {
addToast('error', err.detail || '주제 생성 실패'); addToast('error', err.detail || '주제 생성 실패');
} finally { } finally {
@@ -101,7 +133,10 @@
description: t.description ?? '', description: t.description ?? '',
color: t.color ?? '', color: t.color ?? '',
study_type: t.study_type ?? '', study_type: t.study_type ?? '',
exam_round_size: t.exam_round_size ?? '',
exam_subjects: [...(t.exam_subjects ?? [])],
}; };
edit_subject_input = '';
} }
async function saveEdit() { async function saveEdit() {
@@ -119,6 +154,10 @@
description: editing.description || null, description: editing.description || null,
color: editing.color || null, color: editing.color || null,
study_type: editing.study_type || null, study_type: editing.study_type || null,
exam_round_size: editing.exam_round_size === '' || editing.exam_round_size == null
? null
: Number(editing.exam_round_size),
exam_subjects: editing.exam_subjects ?? [],
}), }),
}); });
topics = topics.map((x) => (x.id === updated.id ? updated : x)); topics = topics.map((x) => (x.id === updated.id ? updated : x));
@@ -179,6 +218,40 @@
<Textarea label="설명 (선택)" bind:value={f_description} rows={2} placeholder="이 주제의 학습 목표, 시험 일정 등" /> <Textarea label="설명 (선택)" bind:value={f_description} rows={2} placeholder="이 주제의 학습 목표, 시험 일정 등" />
<Select label="분류 (선택)" bind:value={f_study_type} options={STUDY_TYPE_OPTIONS} /> <Select label="분류 (선택)" bind:value={f_study_type} options={STUDY_TYPE_OPTIONS} />
<TextInput label="색상 (선택, hex)" bind:value={f_color} placeholder="#3B82F6" /> <TextInput label="색상 (선택, hex)" bind:value={f_color} placeholder="#3B82F6" />
<!-- PR-6: 시험 정보 -->
<div class="border-t border-default pt-3 mt-1">
<div class="text-xs font-semibold text-dim mb-2">시험 정보 (선택)</div>
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">회차당 문항 수 (1~300)</span>
<input type="number" min="1" max="300" bind:value={f_exam_round_size}
placeholder="예: 100"
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
<div class="flex flex-col gap-1.5">
<span class="text-xs text-dim">과목 리스트 (입력 시 드롭다운 옵션)</span>
<div class="flex gap-2">
<input type="text" bind:value={f_subject_input}
placeholder="예: 가스유체역학"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSubject(); } }}
class="flex-1 px-3 py-1.5 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<Button size="sm" variant="ghost" onclick={addSubject} icon={Plus}>추가</Button>
</div>
{#if f_exam_subjects.length > 0}
<div class="flex flex-wrap gap-1.5 mt-1">
{#each f_exam_subjects as s}
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-accent/10 border border-accent/30 text-xs text-text">
{s}
<button type="button" onclick={() => removeSubject(s)} class="text-dim hover:text-error" aria-label="제거">×</button>
</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<Button variant="ghost" onclick={() => (formOpen = false)} disabled={creating}>취소</Button> <Button variant="ghost" onclick={() => (formOpen = false)} disabled={creating}>취소</Button>
<Button onclick={createTopic} loading={creating}>생성</Button> <Button onclick={createTopic} loading={creating}>생성</Button>
@@ -263,6 +336,39 @@
<Textarea label="설명" bind:value={editing.description} rows={2} /> <Textarea label="설명" bind:value={editing.description} rows={2} />
<Select label="분류" bind:value={editing.study_type} options={STUDY_TYPE_OPTIONS} /> <Select label="분류" bind:value={editing.study_type} options={STUDY_TYPE_OPTIONS} />
<TextInput label="색상 (hex)" bind:value={editing.color} placeholder="#3B82F6" /> <TextInput label="색상 (hex)" bind:value={editing.color} placeholder="#3B82F6" />
<!-- PR-6: 시험 정보 -->
<div class="border-t border-default pt-3 mt-1">
<div class="text-xs font-semibold text-dim mb-2">시험 정보</div>
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">회차당 문항 수 (1~300, 비우면 미설정)</span>
<input type="number" min="1" max="300" bind:value={editing.exam_round_size}
placeholder="예: 100"
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
<div class="flex flex-col gap-1.5">
<span class="text-xs text-dim">과목 리스트</span>
<div class="flex gap-2">
<input type="text" bind:value={edit_subject_input}
placeholder="예: 가스유체역학"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addEditSubject(); } }}
class="flex-1 px-3 py-1.5 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<Button size="sm" variant="ghost" onclick={addEditSubject} icon={Plus}>추가</Button>
</div>
{#if editing.exam_subjects.length > 0}
<div class="flex flex-wrap gap-1.5 mt-1">
{#each editing.exam_subjects as s}
<span class="inline-flex items-center gap-1 px-2 py-1 rounded bg-accent/10 border border-accent/30 text-xs text-text">
{s}
<button type="button" onclick={() => removeEditSubject(s)} class="text-dim hover:text-error" aria-label="제거">×</button>
</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div> </div>
<div class="flex gap-2 justify-end mt-4"> <div class="flex gap-2 justify-end mt-4">
<Button variant="ghost" onclick={() => (editing = null)} disabled={savingEdit}>취소</Button> <Button variant="ghost" onclick={() => (editing = null)} disabled={savingEdit}>취소</Button>
@@ -13,6 +13,7 @@
import { import {
ArrowLeft, FolderKanban, PenLine, BookOpen, Plus, Trash2, ArrowRight, Languages, ArrowLeft, FolderKanban, PenLine, BookOpen, Plus, Trash2, ArrowRight, Languages,
ChevronRight, ChevronDown, FolderOpen, FolderPlus, HelpCircle, Edit, Play, CheckCircle2, XCircle, ChevronRight, ChevronDown, FolderOpen, FolderPlus, HelpCircle, Edit, Play, CheckCircle2, XCircle,
ListChecks,
} from 'lucide-svelte'; } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
@@ -510,7 +511,8 @@
<HelpCircle size={14} class="text-accent" /> 문제 <HelpCircle size={14} class="text-accent" /> 문제
<span class="text-[10px] text-dim">{detail.sections.questions?.length ?? 0}</span> <span class="text-[10px] text-dim">{detail.sections.questions?.length ?? 0}</span>
</h2> </h2>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1 flex-wrap">
<Button href={`/study/topics/${topicId}/exam-rounds`} size="sm" variant="ghost" icon={ListChecks}>회차 보기</Button>
<Button href={`/study/topics/${topicId}/questions/new`} size="sm" variant="ghost" icon={Plus}> 문제</Button> <Button href={`/study/topics/${topicId}/questions/new`} size="sm" variant="ghost" icon={Plus}> 문제</Button>
{#if (detail.sections.questions?.length ?? 0) > 0} {#if (detail.sections.questions?.length ?? 0) > 0}
<Button href={`/study/topics/${topicId}/review`} size="sm" icon={Play}>복습 시작</Button> <Button href={`/study/topics/${topicId}/review`} size="sm" icon={Play}>복습 시작</Button>
@@ -0,0 +1,168 @@
<script>
/**
* /study/topics/[id]/exam-rounds — 회차 목록 + 진행률 (PR-6).
*
* 토픽의 exam_round_size 가 있으면 진행 바 + "N번부터 이어서" 동선.
* 새 회차 시작 모달 → /questions/new?exam_round=...&start_qnum=1 로 이동.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Plus, ArrowRight, CheckCircle2, ListChecks } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
let examRoundSize = $state(null);
let items = $state([]);
let loading = $state(true);
// 새 회차 시작 모달
let newRoundModal = $state(false);
let newRoundName = $state('');
async function load() {
loading = true;
try {
const [t, r] = await Promise.all([
api(`/study-topics/${topicId}`),
api(`/study-topics/${topicId}/exam-rounds`),
]);
topicName = t?.topic?.name ?? '';
examRoundSize = r.exam_round_size;
items = r.items;
} catch (err) {
addToast('error', err.detail || '회차 목록 로딩 실패');
} finally {
loading = false;
}
}
onMount(load);
function startNewRound() {
if (!newRoundName.trim()) {
addToast('error', '회차명을 입력하세요');
return;
}
const r = newRoundName.trim();
newRoundModal = false;
newRoundName = '';
const q = new URLSearchParams({ exam_round: r, start_qnum: '1' });
goto(`/study/topics/${topicId}/questions/new?${q}`);
}
function resumeRound(it) {
const q = new URLSearchParams({
exam_round: it.exam_round,
start_qnum: String(it.next_question_number ?? it.max_question_number + 1),
});
goto(`/study/topics/${topicId}/questions/new?${q}`);
}
function progressPercent(it) {
if (!examRoundSize) return 0;
return Math.min(100, Math.round((it.question_count / examRoundSize) * 100));
}
</script>
<svelte:head><title>회차 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-4xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3">
<a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a>
<span class="text-faint">/</span>
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span>
<span class="text-text font-medium flex items-center gap-1.5"><ListChecks size={14} class="text-accent" /> 회차</span>
</div>
<header class="flex items-center justify-between mb-4 flex-wrap gap-2">
<div>
<h1 class="text-lg font-semibold text-text">{topicName} — 회차</h1>
<p class="text-xs text-dim mt-1">
{#if examRoundSize}회차당 <span class="text-text">{examRoundSize}</span>문항 기준{:else}회차당 문항 수 미설정 (주제 편집에서 설정){/if}
</p>
</div>
<Button onclick={() => (newRoundModal = true)} icon={Plus}>새 회차 시작</Button>
</header>
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Array(3) as _}<Skeleton h="h-24" rounded="lg" />{/each}
</div>
{:else if items.length === 0}
<EmptyState
icon={ListChecks}
title="회차가 없습니다"
description="첫 회차(예: '2019년 1회')를 시작하면 여기에 진행률이 누적됩니다."
/>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each items as it (it.exam_round)}
<div class="p-4 rounded-lg border border-default bg-surface flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-text truncate">{it.exam_round}</span>
{#if it.is_complete}
<span class="text-[10px] text-success border border-success/40 rounded px-1.5 py-0.5 flex items-center gap-1">
<CheckCircle2 size={10} /> 완료
</span>
{/if}
<span class="ml-auto text-xs text-dim">
<span class="text-text">{it.question_count}</span>{#if examRoundSize}<span class="text-faint">/{examRoundSize}</span>{/if}
</span>
</div>
{#if examRoundSize}
<div class="h-1.5 bg-bg rounded-full overflow-hidden">
<div class="h-full bg-accent transition-all" style="width: {progressPercent(it)}%"></div>
</div>
{/if}
<div class="flex items-center justify-between mt-1">
<span class="text-[11px] text-dim">
{#if it.is_complete}모든 문항 입력 완료{:else}다음: <span class="text-text">{it.next_question_number}</span>{/if}
</span>
{#if !it.is_complete}
<Button size="sm" onclick={() => resumeRound(it)} icon={ArrowRight} iconPosition="right">
{it.next_question_number}번부터 이어서
</Button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- 새 회차 모달 -->
{#if newRoundModal}
<button type="button" aria-label="모달 닫기" onclick={() => (newRoundModal = false)}
class="fixed inset-0 z-40 bg-black/40"></button>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div class="w-full max-w-sm bg-surface rounded-lg border border-default shadow-xl p-4 pointer-events-auto">
<div class="text-sm font-semibold text-text mb-3">새 회차 시작</div>
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">회차명</span>
<input type="text" bind:value={newRoundName}
placeholder="예: 2019년 1회"
onkeydown={(e) => { if (e.key === 'Enter') startNewRound(); }}
class="px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
<p class="text-[11px] text-dim mt-2">
같은 토픽 안에서 회차명은 자유 텍스트입니다. "2019년 1회" 같은 형태로 입력하면 진행률 추적이 가능합니다.
</p>
<div class="flex gap-2 justify-end mt-4">
<Button variant="ghost" onclick={() => (newRoundModal = false)}>취소</Button>
<Button onclick={startNewRound}>시작</Button>
</div>
</div>
</div>
{/if}
@@ -1,18 +1,23 @@
<script> <script>
/** /**
* /study/topics/[id]/questions/new — 문제 입력 페이지. * /study/topics/[id]/questions/new — 문제 입력 페이지 (PR-6 보강).
* *
* 하루 100문제 입력 시나리오에 맞춰 빠른 반복 입력 UX: * 시험·회차·문항 자동 관리:
* - "저장 후 계속 입력" → subject/scope/exam_name/exam_round 유지, 본문·보기·정답만 초기화 * - 시험명: topic.name 자동 prefill (수정 가능)
* - sessionStorage 캐시: 페이지 새로고침해도 분류 필드 유지 * - 과목: 드롭다운 (topic.exam_subjects + 기존 question.subject distinct + 직접 입력)
* - 입력 검증 실패 시 토스트 * - 회차: 드롭다운 (기존 distinct + 새 회차 입력). query string ?exam_round= prefill
* - 문항 번호: 회차 선택 시 next_question_number 자동, query ?start_qnum= prefill
* - 출처/메모: 비어있으면 자동 합성 "{exam_round} {N}번"
* - 저장 후 계속 입력: 본문/보기/정답 reset, 시험명/과목/회차 유지, 문항 번호 +1
* - 회차 변경 시 문항 번호 1로 reset
* - exam_round_size 도달 시 안내 + 회차 강조
*/ */
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast'; import { addToast } from '$lib/stores/toast';
import { ArrowLeft, Save, Repeat } from 'lucide-svelte'; import { ArrowLeft, Save, Repeat, ListChecks, AlertCircle } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte'; import Card from '$lib/components/ui/Card.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte'; import TextInput from '$lib/components/ui/TextInput.svelte';
@@ -20,6 +25,11 @@
let topicId = $derived(Number($page.params.id)); let topicId = $derived(Number($page.params.id));
let topicName = $state(''); let topicName = $state('');
let topicExamSubjects = $state([]); // study_topics.exam_subjects
let topicExamRoundSize = $state(null);
// 회차 진행률 캐시 (드롭다운 선택 시 next_question_number 조회용)
let examRounds = $state([]); // [{exam_round, max_question_number, next_question_number, is_complete}]
// 입력 필드 // 입력 필드
let q_text = $state(''); let q_text = $state('');
@@ -29,50 +39,153 @@
let c4 = $state(''); let c4 = $state('');
let correct = $state(1); let correct = $state(1);
// persistent (sessionStorage 동기화) // 시험·과목·회차 (저장 후 계속 입력 시 유지)
let subject = $state(''); let f_exam_name = $state('');
let scope = $state(''); let f_subject = $state(''); // dropdown 값. 빈 값 = 직접 입력 모드
let exam_name = $state(''); let f_subject_custom = $state(''); // 직접 입력 모드 시 값
let exam_round = $state(''); let f_subject_mode = $state('dropdown'); // 'dropdown' | 'custom'
let f_scope = $state('');
let f_exam_round = $state('');
let f_exam_round_mode = $state('select'); // 'select' | 'new'
let f_exam_round_new = $state('');
let f_qnum = $state(1); // 문항 번호
// 한 번 입력 후 유지 안 함 // 본문 자동 reset 안 되는 메타 (편의성 — 비워둠이 default)
let explanation = $state(''); let explanation = $state('');
let source_note = $state(''); let source_note = $state('');
let saving = $state(false); let saving = $state(false);
let isCompleteRound = $state(false); // 회차 도달 여부
const STORAGE_KEY = $derived(`study_q_persist_${topicId}`); const STORAGE_KEY = $derived(`study_q_persist_v2_${topicId}`);
function persist() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
f_exam_name, f_subject, f_subject_custom, f_subject_mode,
f_scope, f_exam_round, f_qnum,
}));
} catch {}
}
function loadPersist() { function loadPersist() {
try { try {
const raw = sessionStorage.getItem(STORAGE_KEY); const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) { if (!raw) return;
const p = JSON.parse(raw); const p = JSON.parse(raw);
subject = p.subject ?? ''; f_exam_name = p.f_exam_name ?? f_exam_name;
scope = p.scope ?? ''; f_subject = p.f_subject ?? '';
exam_name = p.exam_name ?? ''; f_subject_custom = p.f_subject_custom ?? '';
exam_round = p.exam_round ?? ''; f_subject_mode = p.f_subject_mode ?? 'dropdown';
} f_scope = p.f_scope ?? '';
} catch {} f_exam_round = p.f_exam_round ?? '';
} f_qnum = Number(p.f_qnum) || 1;
function savePersist() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ subject, scope, exam_name, exam_round }));
} catch {} } catch {}
} }
async function loadTopic() { // 같은 토픽의 기존 question.subject distinct (드롭다운 fallback)
let questionSubjects = $state([]);
async function loadTopicAndRounds() {
try { try {
const t = await api(`/study-topics/${topicId}`); const [t, r] = await Promise.all([
api(`/study-topics/${topicId}`),
api(`/study-topics/${topicId}/exam-rounds`),
]);
topicName = t?.topic?.name ?? ''; topicName = t?.topic?.name ?? '';
} catch {} topicExamSubjects = t?.topic?.exam_subjects ?? [];
topicExamRoundSize = t?.topic?.exam_round_size ?? null;
examRounds = r.items ?? [];
// exam_name 미설정 시 토픽명 prefill
if (!f_exam_name) f_exam_name = topicName;
// 기존 입력 question 의 subject distinct 도 가져옴
try {
const list = await api(`/study-topics/${topicId}/questions?page_size=200`);
const subjs = new Set();
for (const q of (list.items ?? [])) {
if (q.subject) subjs.add(q.subject);
}
questionSubjects = Array.from(subjs);
} catch {}
} catch (err) {
addToast('error', err.detail || '주제 로딩 실패');
}
} }
onMount(() => { // query string 처리 (회차 목록에서 ?exam_round=&start_qnum= 로 진입)
function applyQueryParams() {
const params = $page.url.searchParams;
const r = params.get('exam_round');
const sq = params.get('start_qnum');
if (r) {
f_exam_round = r;
f_exam_round_mode = 'select';
}
if (sq) {
const n = Number(sq);
if (Number.isFinite(n) && n > 0) f_qnum = n;
}
}
onMount(async () => {
loadPersist(); loadPersist();
loadTopic(); applyQueryParams();
await loadTopicAndRounds();
refreshCompleteFlag();
}); });
// 드롭다운 옵션 합치기 (topic.exam_subjects + 기존 distinct, unique)
let subjectOptions = $derived(() => {
const set = new Set();
for (const s of topicExamSubjects) if (s) set.add(s);
for (const s of questionSubjects) if (s) set.add(s);
return Array.from(set);
});
// 회차 변경 감지 → 문항 번호 자동 reset
let lastExamRound = $state('');
$effect(() => {
if (f_exam_round && f_exam_round !== lastExamRound) {
// 기존 회차면 next_question_number, 아니면 1
const found = examRounds.find((r) => r.exam_round === f_exam_round);
if (found && found.next_question_number) {
f_qnum = found.next_question_number;
} else if (!found) {
f_qnum = 1;
}
lastExamRound = f_exam_round;
refreshCompleteFlag();
}
});
function refreshCompleteFlag() {
const found = examRounds.find((r) => r.exam_round === f_exam_round);
isCompleteRound = !!(found && found.is_complete);
}
function effectiveSubject() {
return f_subject_mode === 'custom' ? f_subject_custom.trim() : f_subject;
}
function autoSourceNote() {
if (source_note.trim()) return source_note.trim();
if (f_exam_round) return `${f_exam_round} ${f_qnum}번`;
return '';
}
function applyNewRound() {
if (!f_exam_round_new.trim()) {
addToast('error', '회차명을 입력하세요');
return;
}
f_exam_round = f_exam_round_new.trim();
f_exam_round_new = '';
f_exam_round_mode = 'select';
f_qnum = 1;
lastExamRound = f_exam_round;
refreshCompleteFlag();
}
function validate() { function validate() {
if (!q_text.trim()) { addToast('error', '문제 본문을 입력하세요'); return false; } if (!q_text.trim()) { addToast('error', '문제 본문을 입력하세요'); return false; }
if (!c1.trim() || !c2.trim() || !c3.trim() || !c4.trim()) { if (!c1.trim() || !c2.trim() || !c3.trim() || !c4.trim()) {
@@ -81,20 +194,24 @@
if (![1, 2, 3, 4].includes(Number(correct))) { if (![1, 2, 3, 4].includes(Number(correct))) {
addToast('error', '정답은 1~4 중 하나'); return false; addToast('error', '정답은 1~4 중 하나'); return false;
} }
if (isCompleteRound) {
addToast('error', '이 회차는 이미 완료되었습니다. 다른 회차를 선택하거나 새 회차를 시작하세요.');
return false;
}
return true; return true;
} }
function clearForCont() { function clearForCont() {
q_text = ''; c1 = ''; c2 = ''; c3 = ''; c4 = ''; correct = 1; q_text = ''; c1 = ''; c2 = ''; c3 = ''; c4 = ''; correct = 1;
explanation = ''; source_note = ''; explanation = ''; source_note = '';
// subject/scope/exam_name/exam_round 는 유지
} }
async function save(continueAfter) { async function save(continueAfter) {
if (!validate()) return; if (!validate()) return;
saving = true; saving = true;
savePersist(); persist();
try { try {
const subj = effectiveSubject();
const body = { const body = {
question_text: q_text.trim(), question_text: q_text.trim(),
choice_1: c1.trim(), choice_1: c1.trim(),
@@ -102,21 +219,25 @@
choice_3: c3.trim(), choice_3: c3.trim(),
choice_4: c4.trim(), choice_4: c4.trim(),
correct_choice: Number(correct), correct_choice: Number(correct),
subject: subject || null, subject: subj || null,
scope: scope || null, scope: f_scope || null,
exam_name: exam_name || null, exam_name: f_exam_name || null,
exam_round: exam_round || null, exam_round: f_exam_round || null,
exam_question_number: f_exam_round ? Number(f_qnum) : null,
explanation: explanation || null, explanation: explanation || null,
source_note: source_note || null, source_note: autoSourceNote() || null,
}; };
await api(`/study-topics/${topicId}/questions`, { await api(`/study-topics/${topicId}/questions`, {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
addToast('success', '문제 저장됨'); addToast('success', `문제 저장됨${f_exam_round ? ` (${f_exam_round} ${f_qnum})` : ''}`);
if (continueAfter) { if (continueAfter) {
clearForCont(); clearForCont();
// 본문 textarea 로 포커스 이동 f_qnum = Number(f_qnum) + 1;
// 회차 진행률 갱신 (도달 체크)
await refreshExamRounds();
persist();
setTimeout(() => document.getElementById('q-text')?.focus(), 0); setTimeout(() => document.getElementById('q-text')?.focus(), 0);
} else { } else {
goto(`/study/topics/${topicId}`); goto(`/study/topics/${topicId}`);
@@ -127,12 +248,28 @@
saving = false; saving = false;
} }
} }
async function refreshExamRounds() {
try {
const r = await api(`/study-topics/${topicId}/exam-rounds`);
examRounds = r.items ?? [];
refreshCompleteFlag();
} catch {}
}
// 진행률 표시 (현재 회차)
let currentProgress = $derived(() => {
if (!f_exam_round) return null;
const found = examRounds.find((r) => r.exam_round === f_exam_round);
const before = found?.question_count ?? 0;
return { before, size: topicExamRoundSize };
});
</script> </script>
<svelte:head><title>새 문제 — {topicName || '주제'}</title></svelte:head> <svelte:head><title>새 문제 — {topicName || '주제'}</title></svelte:head>
<div class="p-4 md:p-6 max-w-3xl mx-auto"> <div class="p-4 md:p-6 max-w-3xl mx-auto">
<div class="flex items-center gap-2 text-xs md:text-sm mb-3"> <div class="flex items-center gap-2 text-xs md:text-sm mb-3 flex-wrap">
<a href="/study" class="text-dim hover:text-text">공부</a> <a href="/study" class="text-dim hover:text-text">공부</a>
<span class="text-faint">/</span> <span class="text-faint">/</span>
<a href="/study/topics" class="text-dim hover:text-text">주제</a> <a href="/study/topics" class="text-dim hover:text-text">주제</a>
@@ -140,18 +277,26 @@
<a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a> <a href={`/study/topics/${topicId}`} class="text-dim hover:text-text truncate">{topicName || '...'}</a>
<span class="text-faint">/</span> <span class="text-faint">/</span>
<span class="text-text font-medium">새 문제</span> <span class="text-text font-medium">새 문제</span>
<a href={`/study/topics/${topicId}/exam-rounds`} class="ml-auto text-xs text-accent hover:underline flex items-center gap-1">
<ListChecks size={12} /> 회차 보기
</a>
</div> </div>
<!-- 회차 도달 안내 -->
{#if isCompleteRound}
<div class="mb-3 p-3 rounded-lg border border-warning/40 bg-warning/5 text-xs text-text flex items-start gap-2">
<AlertCircle size={14} class="text-warning shrink-0 mt-0.5" />
<div>
<span class="font-semibold">{f_exam_round}</span> 회차는 {topicExamRoundSize}문항 모두 입력됨. 다른 회차를 선택하거나 "새 회차" 를 시작하세요.
</div>
</div>
{/if}
<Card class="mb-3"> <Card class="mb-3">
{#snippet children()} {#snippet children()}
<div class="p-4 flex flex-col gap-3"> <div class="p-4 flex flex-col gap-3">
<Textarea <Textarea label="문제 본문" bind:value={q_text} rows={3}
label="문제 본문" placeholder="예: 다음 중 가연성 가스의 폭발범위에 대한 설명으로 옳은 것은?" id="q-text" />
bind:value={q_text}
rows={3}
placeholder="예: 다음 중 가연성 가스의 폭발범위에 대한 설명으로 옳은 것은?"
id="q-text"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<TextInput label="① 1번 보기" bind:value={c1} /> <TextInput label="① 1번 보기" bind:value={c1} />
@@ -164,27 +309,97 @@
<label class="text-xs text-dim">정답 번호</label> <label class="text-xs text-dim">정답 번호</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#each [1, 2, 3, 4] as n} {#each [1, 2, 3, 4] as n}
<button <button type="button" onclick={() => (correct = n)}
type="button"
onclick={() => (correct = n)}
class="px-4 py-2 rounded border text-sm font-medium transition-colors class="px-4 py-2 rounded border text-sm font-medium transition-colors
{correct === n ? 'bg-accent text-white border-accent' : 'bg-surface text-dim border-default hover:text-text'}" {correct === n ? 'bg-accent text-white border-accent' : 'bg-surface text-dim border-default hover:text-text'}"
aria-pressed={correct === n} aria-pressed={correct === n}>{n}</button>
>{n}</button>
{/each} {/each}
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-2 border-t border-default"> <!-- 시험·과목·회차·문항 번호 -->
<TextInput label="과목 (유지)" bind:value={subject} placeholder="예: 연소공학" /> <div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-default">
<TextInput label="범위 (유지)" bind:value={scope} placeholder="예: 폭발범위" /> <TextInput label="시험명 (자동)" bind:value={f_exam_name} placeholder="예: 가스기사" />
<TextInput label="시험명 (유지)" bind:value={exam_name} placeholder="예: 가스기사" />
<TextInput label="회차 (유지)" bind:value={exam_round} placeholder="예: 2024년 1회" /> <!-- 과목 dropdown / 직접 입력 -->
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">과목</label>
{#if f_subject_mode === 'dropdown'}
<div class="flex gap-2">
<select bind:value={f_subject}
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent">
<option value="">— 선택 —</option>
{#each subjectOptions() as s}
<option value={s}>{s}</option>
{/each}
</select>
<button type="button" onclick={() => { f_subject_mode = 'custom'; f_subject_custom = f_subject; }}
class="text-[11px] text-accent hover:underline shrink-0">직접 입력</button>
</div>
{:else}
<div class="flex gap-2">
<input type="text" bind:value={f_subject_custom}
placeholder="과목 직접 입력"
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<button type="button" onclick={() => { f_subject_mode = 'dropdown'; f_subject = f_subject_custom; }}
class="text-[11px] text-dim hover:text-text shrink-0">목록에서 선택</button>
</div>
{/if}
</div>
<TextInput label="범위 (선택)" bind:value={f_scope} placeholder="예: 폭발범위" />
<!-- 회차 dropdown / 새 회차 -->
<div class="flex flex-col gap-1.5">
<label class="text-xs text-dim">회차</label>
{#if f_exam_round_mode === 'select'}
<div class="flex gap-2">
<select bind:value={f_exam_round}
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent">
<option value="">— 선택 —</option>
{#each examRounds as r}
<option value={r.exam_round}>
{r.exam_round} ({r.question_count}{topicExamRoundSize ? `/${topicExamRoundSize}` : ''})
</option>
{/each}
</select>
<button type="button" onclick={() => (f_exam_round_mode = 'new')}
class="text-[11px] text-accent hover:underline shrink-0">새 회차</button>
</div>
{:else}
<div class="flex gap-2">
<input type="text" bind:value={f_exam_round_new}
placeholder="예: 2019년 1회"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyNewRound(); } }}
class="flex-1 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
<button type="button" onclick={applyNewRound} class="text-[11px] text-accent hover:underline shrink-0">확정</button>
<button type="button" onclick={() => (f_exam_round_mode = 'select')} class="text-[11px] text-dim hover:text-text shrink-0">취소</button>
</div>
{/if}
</div>
</div>
<!-- 문항 번호 + 진행률 -->
<div class="flex items-end gap-3 flex-wrap">
<label class="flex flex-col gap-1.5">
<span class="text-xs text-dim">문항 번호</span>
<input type="number" min="1" bind:value={f_qnum}
class="w-28 px-3 py-2 bg-surface border border-default rounded text-sm text-text outline-none focus:border-accent" />
</label>
{#if currentProgress() && currentProgress().size}
<div class="flex-1 min-w-32">
<div class="text-[11px] text-dim mb-1">진행률 {currentProgress().before}/{currentProgress().size}</div>
<div class="h-1.5 bg-bg rounded-full overflow-hidden">
<div class="h-full bg-accent transition-all"
style="width: {Math.min(100, (currentProgress().before / currentProgress().size) * 100)}%"></div>
</div>
</div>
{/if}
</div> </div>
<div class="text-[11px] text-dim -mt-2">"유지" 표시 필드는 다음 입력에도 그대로 유지됩니다 (sessionStorage).</div>
<Textarea label="해설 (선택)" bind:value={explanation} rows={2} placeholder="정답 근거 요약" /> <Textarea label="해설 (선택)" bind:value={explanation} rows={2} placeholder="정답 근거 요약" />
<TextInput label="출처/메모 (선택)" bind:value={source_note} placeholder="예: 산업안전기사 2023 1회 기출 7번" /> <TextInput label="출처/메모 (자동: '회차 N번', 수정 가능)" bind:value={source_note}
placeholder={autoSourceNote() || "예: 산업안전기사 2023 1회 기출 7번"} />
</div> </div>
{/snippet} {/snippet}
</Card> </Card>
@@ -193,7 +408,7 @@
<Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button> <Button href={`/study/topics/${topicId}`} variant="ghost" icon={ArrowLeft}>주제로</Button>
<div class="flex gap-2"> <div class="flex gap-2">
<Button onclick={() => save(false)} loading={saving} variant="ghost" icon={Save}>저장</Button> <Button onclick={() => save(false)} loading={saving} variant="ghost" icon={Save}>저장</Button>
<Button onclick={() => save(true)} loading={saving} icon={Repeat}>저장 계속 입력</Button> <Button onclick={() => save(true)} loading={saving} icon={Repeat} disabled={isCompleteRound}>저장 후 계속 입력</Button>
</div> </div>
</div> </div>
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
-- 195_study_topic_exam_meta.sql (1/3)
-- 시험 메타: 회차당 문항 수 + 과목 리스트 (PR-6).
--
-- 기사시험은 시험마다 회차당 문항 수가 다름 (80/100/120 등). 회차별 진행률 추적·
-- 재개 UX 의 기반. 과목 리스트는 입력 페이지 드롭다운 옵션 (사용자가 시험 생성 시
-- 한 번에 등록).
--
-- CHECK 1~300: 0/음수/비정상 큰 값으로 진행률 UI 깨지는 것 차단. 기사시험 도메인
-- 에선 300 이면 충분.
-- exam_subjects DEFAULT '[]' — 빈 배열이면 입력 페이지가 기존 question.subject
-- distinct 만 사용.
ALTER TABLE study_topics
ADD COLUMN IF NOT EXISTS exam_round_size INTEGER
CHECK (exam_round_size IS NULL OR exam_round_size BETWEEN 1 AND 300),
ADD COLUMN IF NOT EXISTS exam_subjects JSONB NOT NULL DEFAULT '[]'::jsonb;
@@ -0,0 +1,14 @@
-- 196_study_questions_exam_qnum.sql (2/3)
-- 회차 안 문항 번호. 같은 (study_topic_id, exam_round) 묶음 안에서 1, 2, 3, ...
--
-- NULL 허용 — 기존 행은 NULL (마이그레이션 적용 전 입력 데이터).
-- CHECK > 0 — 0/음수 차단. 상한은 두지 않음 (회차 사이즈가 토픽별로 가변).
-- UNIQUE (study_topic_id, exam_round, exam_question_number) 는 두지 않음 — 중복
-- 입력 실수 시 사용자가 직접 수정하도록.
--
-- exam_question_number 변경은 의미 검색 영향 없음 → embedding stale 트리거에서
-- 제외 (study_questions.py PATCH 핸들러).
ALTER TABLE study_questions
ADD COLUMN IF NOT EXISTS exam_question_number SMALLINT
CHECK (exam_question_number IS NULL OR exam_question_number > 0);
@@ -0,0 +1,8 @@
-- 197_study_questions_exam_round_idx.sql (3/3)
-- 회차별 진행률 (max + count) 조회 고속화.
-- partial — 회차 미설정 / soft-deleted 행 제외해서 인덱스 부피 절약.
-- GET /api/study-topics/{id}/exam-rounds 의 GROUP BY exam_round 가 본 인덱스 활용.
CREATE INDEX IF NOT EXISTS idx_study_questions_topic_round_qnum
ON study_questions (study_topic_id, exam_round, exam_question_number)
WHERE deleted_at IS NULL AND exam_round IS NOT NULL;