feat: 밀린 복습 자동 이월 + 페르소나 재계산 + PDF OCR 문제 분리
- 복습 큐 조회 시 하루 이상 밀린 pending 항목을 오늘 09:00 KST로 자동 reschedule - 페르소나/학습강도 변경 시 모든 pending 복습 스케줄을 새 설정 기반으로 재계산 - PDF 모의고사 업로드 → Codex Vision OCR로 문제별 분리 → 태그 배정 → 선택적 복습 큐 등록 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { MathUnit, Persona, Prisma, ReviewIntensity } from '@prisma/client';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PersonaForgetService } from '../forget/persona-forget.service';
|
||||
|
||||
export interface OnboardingInput {
|
||||
nickname: string;
|
||||
@@ -35,7 +36,10 @@ interface ProfileInput {
|
||||
|
||||
@Injectable()
|
||||
export class MeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly forget: PersonaForgetService,
|
||||
) {}
|
||||
|
||||
async updateOnboarding(userId: number, input: OnboardingInput) {
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
@@ -60,6 +64,11 @@ export class MeService {
|
||||
}
|
||||
|
||||
async updateProfile(userId: number, input: ProfileInput) {
|
||||
const oldUser = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { persona: true, reviewIntensity: true },
|
||||
});
|
||||
|
||||
const data: Prisma.UserUpdateInput = {};
|
||||
|
||||
if (input.nickname !== undefined) data.nickname = input.nickname;
|
||||
@@ -77,9 +86,94 @@ export class MeService {
|
||||
where: { id: userId },
|
||||
data,
|
||||
});
|
||||
|
||||
const personaChanged =
|
||||
input.persona !== undefined && input.persona !== oldUser.persona;
|
||||
const intensityChanged =
|
||||
input.reviewIntensity !== undefined &&
|
||||
input.reviewIntensity !== oldUser.reviewIntensity;
|
||||
|
||||
if (personaChanged || intensityChanged) {
|
||||
await this.recalculatePendingReviews(userId, user.persona, user.reviewIntensity);
|
||||
}
|
||||
|
||||
return this.toView(user);
|
||||
}
|
||||
|
||||
private async recalculatePendingReviews(
|
||||
userId: number,
|
||||
newPersona: Persona,
|
||||
newIntensity: ReviewIntensity,
|
||||
): Promise<void> {
|
||||
const pendingReviews = await this.prisma.reviewSchedule.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: 'pending',
|
||||
},
|
||||
include: {
|
||||
studyLog: {
|
||||
select: {
|
||||
tagId: true,
|
||||
difficulty: true,
|
||||
baseCorrectRate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const review of pendingReviews) {
|
||||
const { tagId, difficulty, baseCorrectRate } = review.studyLog;
|
||||
|
||||
let snap: { s0: number; lastUpdatedAt: Date } | null = null;
|
||||
if (tagId !== null) {
|
||||
snap = await this.prisma.skillSnapshot.findUnique({
|
||||
where: { userId_tagId: { userId, tagId } },
|
||||
select: { s0: true, lastUpdatedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
// SM-2 경로: SkillSnapshot.s0 가 EF 범위(1.3 ~ 4.0)에 있는 경우
|
||||
if (snap !== null && snap.s0 >= 1.3 && snap.s0 <= 4.0) {
|
||||
const sm2 = this.forget.sm2Schedule({
|
||||
iteration: review.iteration,
|
||||
selfDifficulty: 'medium',
|
||||
currentEF: snap.s0,
|
||||
persona: newPersona,
|
||||
now: review.createdAt,
|
||||
});
|
||||
await this.prisma.reviewSchedule.update({
|
||||
where: { id: review.id },
|
||||
data: { scheduledAt: sm2.scheduledAt },
|
||||
});
|
||||
} else {
|
||||
// 망각곡선 경로
|
||||
const D =
|
||||
baseCorrectRate !== null && baseCorrectRate !== undefined
|
||||
? 1 - baseCorrectRate
|
||||
: difficulty;
|
||||
|
||||
const s0 = snap?.s0 ?? 0.3;
|
||||
const lastUpdatedAt = snap?.lastUpdatedAt ?? review.createdAt;
|
||||
|
||||
const scheduled = this.forget.schedule({
|
||||
s0,
|
||||
persona: newPersona,
|
||||
intensity: newIntensity,
|
||||
difficulty: D,
|
||||
lastUpdatedAt,
|
||||
});
|
||||
|
||||
await this.prisma.reviewSchedule.update({
|
||||
where: { id: review.id },
|
||||
data: {
|
||||
scheduledAt: scheduled.scheduledAt,
|
||||
predictedP: scheduled.predictedP,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateAvatar(
|
||||
userId: number,
|
||||
file?: {
|
||||
|
||||
@@ -19,12 +19,18 @@ export class ReviewsService {
|
||||
* Queue of reviews that should be done around "now". Includes anything
|
||||
* whose scheduledAt is in the past OR within the next 24h so the UI can
|
||||
* show "곧 해야 할 것들" too.
|
||||
*
|
||||
* Before fetching, auto-reschedules overdue pending items (scheduled before
|
||||
* today 00:00 KST) to today 09:00 KST and returns the count in
|
||||
* `rescheduledCount`.
|
||||
*/
|
||||
async queue(userId: number) {
|
||||
const now = new Date();
|
||||
const rescheduledCount = await this.rescheduleOverdue(userId, now);
|
||||
|
||||
const soon = new Date(now.getTime() + 24 * 3_600_000);
|
||||
|
||||
return this.prisma.reviewSchedule.findMany({
|
||||
const items = await this.prisma.reviewSchedule.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: ReviewStatus.pending,
|
||||
@@ -45,6 +51,51 @@ export class ReviewsService {
|
||||
orderBy: { scheduledAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return { items, rescheduledCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 밀린 복습(pending + scheduledAt < 오늘 00:00 KST)을 오늘 09:00 KST로 이동.
|
||||
* 당일 내 밀린 것(오늘 예정이지만 아직 안 한 것)은 대상에서 제외.
|
||||
* 이동한 건수를 반환한다.
|
||||
*/
|
||||
async rescheduleOverdue(userId: number, now: Date): Promise<number> {
|
||||
// KST = UTC+9
|
||||
const kstOffset = 9 * 3_600_000;
|
||||
const kstNow = new Date(now.getTime() + kstOffset);
|
||||
|
||||
// 오늘 KST 기준 자정 (UTC 표현)
|
||||
const utcTodayStartKST = new Date(
|
||||
Date.UTC(kstNow.getUTCFullYear(), kstNow.getUTCMonth(), kstNow.getUTCDate()) - kstOffset,
|
||||
);
|
||||
// 오늘 KST 09:00 (UTC 표현)
|
||||
const utcTodayNineAMKST = new Date(utcTodayStartKST.getTime() + 9 * 3_600_000);
|
||||
|
||||
// Prisma updateMany는 relation filter를 지원하지 않으므로
|
||||
// findMany로 대상 ID 목록을 먼저 조회 후 updateMany { id: { in: ids } } 패턴 사용
|
||||
const overdue = await this.prisma.reviewSchedule.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: ReviewStatus.pending,
|
||||
scheduledAt: { lt: utcTodayStartKST },
|
||||
studyLog: {
|
||||
psProblemId: null,
|
||||
subject: { name: '수학' },
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (overdue.length === 0) return 0;
|
||||
|
||||
const ids = overdue.map((r) => r.id);
|
||||
const result = await this.prisma.reviewSchedule.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { scheduledAt: utcTodayNineAMKST },
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
async submit(
|
||||
|
||||
@@ -153,6 +153,47 @@ class CreateFromProblemSetDto {
|
||||
answers: ProblemSetAnswerDto[];
|
||||
}
|
||||
|
||||
class ImportFromPdfProblemDto {
|
||||
@IsInt()
|
||||
number: number;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
difficulty: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bodyText?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
tagId?: number;
|
||||
|
||||
@IsEnum(StudyResult)
|
||||
result: StudyResult;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['hard', 'medium', 'easy'])
|
||||
selfDifficulty?: 'hard' | 'medium' | 'easy';
|
||||
}
|
||||
|
||||
class ImportFromPdfDto {
|
||||
@IsInt()
|
||||
subjectId: number;
|
||||
|
||||
@IsString()
|
||||
pdfTitle: string;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportFromPdfProblemDto)
|
||||
problems: ImportFromPdfProblemDto[];
|
||||
}
|
||||
|
||||
class UpdateStudyLogDto implements UpdateStudyLogInput {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -232,6 +273,48 @@ export class StudyLogsController {
|
||||
return this.svc.purgeNonMath(user.id);
|
||||
}
|
||||
|
||||
@Post('parse-pdf')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = join(__dirname, '..', '..', 'uploads', 'problems');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = extname(file.originalname).toLowerCase();
|
||||
cb(null, `${Date.now()}-${randomBytes(4).toString('hex')}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype !== 'application/pdf') {
|
||||
cb(new BadRequestException('PDF 파일만 업로드할 수 있어.'), false);
|
||||
} else {
|
||||
cb(null, true);
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
async parsePdf(
|
||||
@CurrentUser() _user: AuthUser,
|
||||
@UploadedFile()
|
||||
file: { filename: string; path: string; originalname: string; mimetype: string; size: number } | undefined,
|
||||
@Body('title') title?: string,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('파일이 없어.');
|
||||
return this.svc.parsePdfToProblems(file.path, title || file.originalname);
|
||||
}
|
||||
|
||||
@Post('import-from-pdf')
|
||||
importFromPdf(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: ImportFromPdfDto,
|
||||
) {
|
||||
return this.svc.importFromParsedPdf(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('upload-pdf')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
|
||||
@@ -288,6 +288,102 @@ export class StudyLogsService {
|
||||
});
|
||||
}
|
||||
|
||||
async parsePdfToProblems(pdfPath: string, title: string) {
|
||||
const { ocrProblemPage } = await import('../problem-sets/parsing/ocr-fallback/index');
|
||||
const { execFileSync } = await import('child_process');
|
||||
|
||||
let pageCount = 1;
|
||||
try {
|
||||
const output = execFileSync('pdfinfo', [pdfPath], { encoding: 'utf-8' });
|
||||
const match = output.match(/^Pages:\s+(\d+)/m);
|
||||
if (match) pageCount = Number(match[1]);
|
||||
} catch {
|
||||
// pdfinfo 미설치 시 1페이지로 시도
|
||||
}
|
||||
|
||||
const allProblems: Array<{ number: number; bodyText: string; choices: Record<string, string> }> = [];
|
||||
|
||||
for (let page = 1; page <= pageCount; page++) {
|
||||
try {
|
||||
const problems = await ocrProblemPage(pdfPath, page);
|
||||
allProblems.push(...problems);
|
||||
} catch {
|
||||
// 페이지 파싱 실패 시 건너뜀
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 번호 제거 (첫 번째 유지)
|
||||
const seen = new Set<number>();
|
||||
const unique = allProblems.filter((p) => {
|
||||
if (seen.has(p.number)) return false;
|
||||
seen.add(p.number);
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
title,
|
||||
problems: unique.sort((a, b) => a.number - b.number),
|
||||
totalPages: pageCount,
|
||||
};
|
||||
}
|
||||
|
||||
async importFromParsedPdf(
|
||||
userId: number,
|
||||
input: {
|
||||
subjectId: number;
|
||||
pdfTitle: string;
|
||||
problems: Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
bodyText?: string;
|
||||
tagId?: number;
|
||||
result: StudyResult;
|
||||
selfDifficulty?: 'hard' | 'medium' | 'easy';
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { persona: true, reviewIntensity: true },
|
||||
});
|
||||
|
||||
// subjectId 소유권 검증
|
||||
const subject = await this.prisma.subject.findFirst({
|
||||
where: { id: input.subjectId, userId },
|
||||
});
|
||||
if (!subject) throw new ForbiddenException('subject');
|
||||
|
||||
const results: Array<{ studyLog: { id: number }; nextReview: { id: number } }> = [];
|
||||
|
||||
for (const problem of input.problems) {
|
||||
const createInput: CreateStudyLogInput = {
|
||||
subjectId: input.subjectId,
|
||||
tagId: problem.tagId,
|
||||
title: `[${input.pdfTitle}] ${problem.number}번 - ${problem.title}`,
|
||||
difficulty: problem.difficulty,
|
||||
result: problem.result,
|
||||
selfDifficulty: problem.selfDifficulty,
|
||||
memo: problem.bodyText || undefined,
|
||||
};
|
||||
|
||||
// tagId 검증
|
||||
if (problem.tagId) {
|
||||
const tag = await this.prisma.tag.findFirst({
|
||||
where: { id: problem.tagId, subjectId: input.subjectId },
|
||||
});
|
||||
if (!tag) throw new NotFoundException(`tag ${problem.tagId}`);
|
||||
}
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
return this.createInTransaction(tx, userId, user, createInput);
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return { imported: results.length, results };
|
||||
}
|
||||
|
||||
async deleteByPrefix(userId: number, prefix: string) {
|
||||
if (!prefix || prefix.length < 2) throw new BadRequestException('prefix too short');
|
||||
|
||||
|
||||
@@ -304,12 +304,12 @@ function ProfileBody() {
|
||||
|
||||
function handlePersonaSelect(value: Persona) {
|
||||
if (!user || value === user.persona) return;
|
||||
void patchProfile({ persona: value }, 'persona');
|
||||
void patchProfile({ persona: value }, 'persona', '페르소나 변경됨 — 복습 스케줄을 새 설정에 맞게 재계산했어요');
|
||||
}
|
||||
|
||||
function handleIntensitySelect(value: ReviewIntensity) {
|
||||
if (!user || value === user.reviewIntensity) return;
|
||||
void patchProfile({ reviewIntensity: value }, 'intensity');
|
||||
void patchProfile({ reviewIntensity: value }, 'intensity', '학습강도 변경됨 — 복습 스케줄을 새 설정에 맞게 재계산했어요');
|
||||
}
|
||||
|
||||
function handleTargetExamYearSave() {
|
||||
|
||||
@@ -121,14 +121,22 @@ export default function ReviewPage() {
|
||||
try {
|
||||
const [meRes, queueRes, summaryRes, activityRes] = await Promise.all([
|
||||
api.get<MeUser>('/auth/me'),
|
||||
api.get<QueueItem[]>('/reviews/queue'),
|
||||
api.get<{ items: QueueItem[]; rescheduledCount: number }>('/reviews/queue'),
|
||||
api.get<DashboardSummary>('/dashboard/summary').catch(() => null),
|
||||
api.get<StudyLog[]>('/study-logs', { params: { limit: 200 } }).catch(() => null),
|
||||
]);
|
||||
|
||||
void meRes;
|
||||
|
||||
const nextQueue = queueRes.data;
|
||||
const { items: nextQueue, rescheduledCount } = queueRes.data;
|
||||
|
||||
if (rescheduledCount > 0) {
|
||||
showToast({
|
||||
variant: 'info',
|
||||
message: `밀린 복습 ${rescheduledCount}개가 오늘로 넘어왔어요`,
|
||||
durationMs: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
setQueue(nextQueue);
|
||||
setInitialTotal(nextQueue.length);
|
||||
|
||||
700
frontend/src/app/study/import-pdf/page.tsx
Normal file
700
frontend/src/app/study/import-pdf/page.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorText,
|
||||
HelpText,
|
||||
Label,
|
||||
PageHeader,
|
||||
SectionTitle,
|
||||
Select,
|
||||
Stack,
|
||||
} from '@/components/ui/primitives';
|
||||
import { api, type StudyResult, type Subject, type Tag } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
interface OcrProblem {
|
||||
number: number;
|
||||
bodyText: string;
|
||||
choices: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
title: string;
|
||||
problems: OcrProblem[];
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface ProblemImportState {
|
||||
selected: boolean;
|
||||
tagId: number | '';
|
||||
result: StudyResult;
|
||||
difficulty: number;
|
||||
selfDifficulty: 'hard' | 'medium' | 'easy' | '';
|
||||
}
|
||||
|
||||
export default function ImportPdfPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<ImportPdfBody />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportPdfBody() {
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [subjects, setSubjects] = useState<Subject[] | null>(null);
|
||||
const [subjectId, setSubjectId] = useState<number | ''>('');
|
||||
const [pdfFile, setPdfFile] = useState<File | null>(null);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
|
||||
const [parsing, setParsing] = useState(false);
|
||||
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
|
||||
const [problemStates, setProblemStates] = useState<ProblemImportState[]>([]);
|
||||
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Subject[]>('/subjects')
|
||||
.then((r) => {
|
||||
setSubjects(r.data);
|
||||
if (r.data[0]) setSubjectId(r.data[0].id);
|
||||
})
|
||||
.catch(() => {
|
||||
setSubjects([]);
|
||||
showToast({ message: '과목 목록을 불러오지 못했습니다', variant: 'danger' });
|
||||
});
|
||||
}, [showToast]);
|
||||
|
||||
const currentSubject = subjects?.find((s) => s.id === subjectId) ?? null;
|
||||
const tags: Tag[] = currentSubject?.tags ?? [];
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setPdfFile(file);
|
||||
if (file && !customTitle) {
|
||||
setCustomTitle(file.name.replace(/\.pdf$/i, ''));
|
||||
}
|
||||
setParseResult(null);
|
||||
setProblemStates([]);
|
||||
setErr(null);
|
||||
};
|
||||
|
||||
const handleParse = async () => {
|
||||
if (!pdfFile) {
|
||||
setErr('PDF 파일을 선택해줘.');
|
||||
return;
|
||||
}
|
||||
if (!subjectId) {
|
||||
setErr('과목을 선택해줘.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErr(null);
|
||||
setParsing(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', pdfFile);
|
||||
if (customTitle) formData.append('title', customTitle);
|
||||
|
||||
const res = await api.post<ParseResult>('/study-logs/parse-pdf', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
const result = res.data;
|
||||
setParseResult(result);
|
||||
setProblemStates(
|
||||
result.problems.map(() => ({
|
||||
selected: false,
|
||||
tagId: '',
|
||||
result: 'incorrect' as StudyResult,
|
||||
difficulty: 0.6,
|
||||
selfDifficulty: '',
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setErr('PDF 분석에 실패했어. 파일을 확인해줘.');
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProblemState = (
|
||||
index: number,
|
||||
patch: Partial<ProblemImportState>,
|
||||
) => {
|
||||
setProblemStates((prev) =>
|
||||
prev.map((state, i) => (i === index ? { ...state, ...patch } : state)),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAll = (selected: boolean) => {
|
||||
setProblemStates((prev) => prev.map((state) => ({ ...state, selected })));
|
||||
};
|
||||
|
||||
const selectedCount = problemStates.filter((s) => s.selected).length;
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!parseResult || !subjectId) return;
|
||||
|
||||
const selectedProblems = parseResult.problems
|
||||
.map((problem, i) => ({ problem, state: problemStates[i] }))
|
||||
.filter(({ state }) => state.selected);
|
||||
|
||||
if (selectedProblems.length === 0) {
|
||||
setErr('등록할 문제를 하나 이상 선택해줘.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErr(null);
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
await api.post('/study-logs/import-from-pdf', {
|
||||
subjectId,
|
||||
pdfTitle: parseResult.title,
|
||||
problems: selectedProblems.map(({ problem, state }) => ({
|
||||
number: problem.number,
|
||||
title: `${problem.number}번`,
|
||||
difficulty: state.difficulty,
|
||||
bodyText: problem.bodyText || undefined,
|
||||
tagId: state.tagId || undefined,
|
||||
result: state.result,
|
||||
selfDifficulty: state.selfDifficulty || undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
showToast({
|
||||
message: `${selectedProblems.length}개 문제를 복습 큐에 등록했어.`,
|
||||
variant: 'success',
|
||||
});
|
||||
router.push('/dashboard');
|
||||
} catch {
|
||||
setErr('등록에 실패했어. 다시 시도해줘.');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (subjects === null) {
|
||||
return <Loading>로딩 중...</Loading>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<HeaderCard>
|
||||
<PageHeader
|
||||
eyebrow="Import"
|
||||
title="PDF 가져오기"
|
||||
subtitle="모의고사 PDF를 업로드하면 OCR로 문제를 분리해서 복습 큐에 등록해줘."
|
||||
right={
|
||||
<HeaderBadge>
|
||||
<Icon name="file-pdf" size={16} />
|
||||
OCR 자동 분리
|
||||
</HeaderBadge>
|
||||
}
|
||||
/>
|
||||
</HeaderCard>
|
||||
|
||||
<StepCard>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<SectionTitle>1단계: 파일 선택</SectionTitle>
|
||||
|
||||
<RowGrid>
|
||||
<Field>
|
||||
<Label>과목</Label>
|
||||
<FieldSelect
|
||||
value={subjectId}
|
||||
onChange={(e) => {
|
||||
setSubjectId(Number(e.target.value));
|
||||
setParseResult(null);
|
||||
setProblemStates([]);
|
||||
}}
|
||||
>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Label>제목 (선택)</Label>
|
||||
<TitleInput
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="예) 2026 수능 수학"
|
||||
/>
|
||||
</Field>
|
||||
</RowGrid>
|
||||
|
||||
<UploadArea
|
||||
$hasFile={!!pdfFile}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{pdfFile ? (
|
||||
<>
|
||||
<Icon name="file-pdf" size={32} weight="duotone" />
|
||||
<UploadFileName>{pdfFile.name}</UploadFileName>
|
||||
<HelpText>클릭해서 파일 변경</HelpText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="file-pdf" size={32} weight="regular" />
|
||||
<UploadLabel>PDF 파일을 선택해줘</UploadLabel>
|
||||
<HelpText>최대 50MB · PDF만 가능</HelpText>
|
||||
</>
|
||||
)}
|
||||
</UploadArea>
|
||||
|
||||
{err && !parseResult && (
|
||||
<ErrorBox role="alert">
|
||||
<Icon name="info" size={16} weight="bold" />
|
||||
<ErrorText as="span">{err}</ErrorText>
|
||||
</ErrorBox>
|
||||
)}
|
||||
|
||||
<ButtonRow>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="primary"
|
||||
$size="md"
|
||||
disabled={!pdfFile || parsing}
|
||||
onClick={handleParse}
|
||||
>
|
||||
{parsing ? '분석 중...' : '분석하기'}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Stack>
|
||||
</StepCard>
|
||||
|
||||
{parseResult && (
|
||||
<StepCard>
|
||||
<Stack $gap={theme.space.md}>
|
||||
<SectionTitleRow>
|
||||
<SectionTitle>
|
||||
2단계: 문제 선택 및 태그 배정 ({parseResult.problems.length}문제 감지)
|
||||
</SectionTitle>
|
||||
<SelectAllRow>
|
||||
<SmallButton type="button" onClick={() => toggleAll(true)}>
|
||||
전체 선택
|
||||
</SmallButton>
|
||||
<SmallButton type="button" onClick={() => toggleAll(false)}>
|
||||
전체 해제
|
||||
</SmallButton>
|
||||
</SelectAllRow>
|
||||
</SectionTitleRow>
|
||||
|
||||
{parseResult.problems.length === 0 ? (
|
||||
<EmptyResult>
|
||||
<Icon name="info" size={24} />
|
||||
<span>감지된 문제가 없어. OCR이 내용을 읽지 못했을 수 있어.</span>
|
||||
</EmptyResult>
|
||||
) : (
|
||||
parseResult.problems.map((problem, index) => {
|
||||
const state = problemStates[index];
|
||||
if (!state) return null;
|
||||
return (
|
||||
<ProblemRow key={problem.number} $selected={state.selected}>
|
||||
<ProblemCheckbox
|
||||
type="checkbox"
|
||||
checked={state.selected}
|
||||
onChange={(e) =>
|
||||
updateProblemState(index, { selected: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<ProblemContent>
|
||||
<ProblemNumber>{problem.number}번</ProblemNumber>
|
||||
{problem.bodyText && (
|
||||
<ProblemPreview>{problem.bodyText.slice(0, 80)}...</ProblemPreview>
|
||||
)}
|
||||
</ProblemContent>
|
||||
<ProblemControls>
|
||||
<MiniField>
|
||||
<MiniLabel>태그</MiniLabel>
|
||||
<MiniSelect
|
||||
value={state.tagId}
|
||||
onChange={(e) =>
|
||||
updateProblemState(index, {
|
||||
tagId: e.target.value === '' ? '' : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</MiniSelect>
|
||||
</MiniField>
|
||||
|
||||
<MiniField>
|
||||
<MiniLabel>결과</MiniLabel>
|
||||
<MiniSelect
|
||||
value={state.result}
|
||||
onChange={(e) =>
|
||||
updateProblemState(index, {
|
||||
result: e.target.value as StudyResult,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="correct">맞음</option>
|
||||
<option value="incorrect">틀림</option>
|
||||
<option value="partial">부분</option>
|
||||
</MiniSelect>
|
||||
</MiniField>
|
||||
|
||||
<MiniField>
|
||||
<MiniLabel>체감 난이도</MiniLabel>
|
||||
<MiniSelect
|
||||
value={state.selfDifficulty}
|
||||
onChange={(e) =>
|
||||
updateProblemState(index, {
|
||||
selfDifficulty: e.target.value as
|
||||
| 'hard'
|
||||
| 'medium'
|
||||
| 'easy'
|
||||
| '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">자동</option>
|
||||
<option value="hard">어려움</option>
|
||||
<option value="medium">보통</option>
|
||||
<option value="easy">쉬움</option>
|
||||
</MiniSelect>
|
||||
</MiniField>
|
||||
</ProblemControls>
|
||||
</ProblemRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<ErrorBox role="alert">
|
||||
<Icon name="info" size={16} weight="bold" />
|
||||
<ErrorText as="span">{err}</ErrorText>
|
||||
</ErrorBox>
|
||||
)}
|
||||
|
||||
<ButtonRow>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="ghost"
|
||||
disabled={importing}
|
||||
onClick={() => {
|
||||
setParseResult(null);
|
||||
setProblemStates([]);
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
다시 분석
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$variant="primary"
|
||||
$size="lg"
|
||||
disabled={importing || selectedCount === 0}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{importing
|
||||
? '등록 중...'
|
||||
: `${selectedCount}개 문제 복습 큐에 등록`}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Stack>
|
||||
</StepCard>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Styled Components ────────────────────────────────────────────────────────
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.space.lg};
|
||||
`;
|
||||
|
||||
const HeaderCard = styled(Card)`
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(79, 70, 229, 0.16), transparent 30%),
|
||||
${theme.color.surfaceDeep};
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const HeaderBadge = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: ${theme.radius.pill};
|
||||
background: rgba(79, 70, 229, 0.14);
|
||||
border: 1px solid rgba(129, 140, 248, 0.3);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const StepCard = styled(Card)`
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const RowGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: ${theme.space.md};
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const Field = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const FieldSelect = styled(Select)`
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
`;
|
||||
|
||||
const TitleInput = styled.input`
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: ${theme.color.brandIndigo};
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: ${theme.color.textSub};
|
||||
}
|
||||
`;
|
||||
|
||||
const UploadArea = styled.div<{ $hasFile: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 160px;
|
||||
border-radius: ${theme.radius.lg};
|
||||
border: 2px dashed
|
||||
${({ $hasFile }) =>
|
||||
$hasFile ? theme.color.brandIndigo : theme.color.borderSoftAlpha};
|
||||
background: ${({ $hasFile }) =>
|
||||
$hasFile ? 'rgba(79, 70, 229, 0.06)' : 'rgba(255, 255, 255, 0.02)'};
|
||||
color: ${theme.color.textSub};
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.color.brandIndigo};
|
||||
background: rgba(79, 70, 229, 0.06);
|
||||
}
|
||||
`;
|
||||
|
||||
const UploadLabel = styled.span`
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const UploadFileName = styled.span`
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${theme.color.textBright};
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ErrorBox = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
padding: 12px 14px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: ${theme.color.danger};
|
||||
`;
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const SectionTitleRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const SelectAllRow = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SmallButton = styled.button`
|
||||
padding: 4px 12px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: ${theme.color.textBright};
|
||||
}
|
||||
`;
|
||||
|
||||
const ProblemRow = styled.div<{ $selected: boolean }>`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: ${theme.radius.lg};
|
||||
border: 1px solid
|
||||
${({ $selected }) =>
|
||||
$selected ? 'rgba(129, 140, 248, 0.4)' : theme.color.borderSoftAlpha};
|
||||
background: ${({ $selected }) =>
|
||||
$selected ? 'rgba(79, 70, 229, 0.06)' : 'rgba(255, 255, 255, 0.02)'};
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
`;
|
||||
|
||||
const ProblemCheckbox = styled.input`
|
||||
margin-top: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
accent-color: ${theme.color.brandIndigo};
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const ProblemContent = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const ProblemNumber = styled.span`
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const ProblemPreview = styled.p`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const ProblemControls = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const MiniField = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 90px;
|
||||
`;
|
||||
|
||||
const MiniLabel = styled.span`
|
||||
font-size: 11px;
|
||||
color: ${theme.color.textSub};
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const MiniSelect = styled.select`
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: ${theme.radius.md};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
border-color: ${theme.color.brandIndigo};
|
||||
}
|
||||
`;
|
||||
|
||||
const EmptyResult = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
color: ${theme.color.textSub};
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const Loading = styled.div`
|
||||
min-height: 40vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: ${theme.color.textSub};
|
||||
font-family: ${theme.font.mono};
|
||||
`;
|
||||
@@ -54,6 +54,12 @@ const BASE_NAV_ITEMS: Array<{
|
||||
icon: 'folders',
|
||||
match: (pathname) => pathname.startsWith('/subjects'),
|
||||
},
|
||||
{
|
||||
href: '/study/import-pdf',
|
||||
label: 'PDF 가져오기',
|
||||
icon: 'file-pdf',
|
||||
match: (pathname) => pathname.startsWith('/study/import-pdf'),
|
||||
},
|
||||
{
|
||||
href: '/pricing',
|
||||
label: '플랜',
|
||||
|
||||
Reference in New Issue
Block a user