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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user