feat: 복습 후 태그 추가 프롬프트 + 캘린더 완료 항목 정렬
1. TagPrompt 공통 모달: 복습/문제풀이 후 태그 선택/생성 가능 - 복습 페이지: 난이도 선택 후 태그 없는 문제에 자동 프롬프트 - 업로드 문제집: 자기평가 후 태그 프롬프트 - 새 태그 생성 기능 (입력 + 즉시 적용) 2. 백엔드: PATCH /study-logs/:id에 tagId 업데이트 지원 3. 캘린더 day panel: 완료 항목 아래로 정렬 + 취소선 + 투명도 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -221,6 +221,11 @@ class UpdateStudyLogDto implements UpdateStudyLogInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
tagId?: number | null;
|
||||
}
|
||||
|
||||
@Controller('study-logs')
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface UpdateStudyLogInput {
|
||||
chosenAnswer?: number;
|
||||
result?: StudyResult;
|
||||
memo?: string;
|
||||
tagId?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateFromProblemSetInput {
|
||||
@@ -614,12 +615,15 @@ export class StudyLogsService {
|
||||
...(data.chosenAnswer !== undefined && {
|
||||
chosenAnswer: data.chosenAnswer,
|
||||
}),
|
||||
...(data.tagId !== undefined && { tagId: data.tagId }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
chosenAnswer: true,
|
||||
result: true,
|
||||
memo: true,
|
||||
tagId: true,
|
||||
tag: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import styled, { css } from 'styled-components';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import DrawingCanvas, { PEN_COLORS, type PenColor } from '@/components/ui/DrawingCanvas';
|
||||
import TagPrompt from '@/components/ui/TagPrompt';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
@@ -66,6 +67,7 @@ function UploadedEbookBody() {
|
||||
const [registeredProblems, setRegisteredProblems] = useState<Set<number>>(new Set());
|
||||
const [registeringProblem, setRegisteringProblem] = useState<number | null>(null);
|
||||
const [registeredDates, setRegisteredDates] = useState<Record<number, Date>>({});
|
||||
const [tagPromptFor, setTagPromptFor] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(id)) {
|
||||
@@ -180,8 +182,10 @@ function UploadedEbookBody() {
|
||||
});
|
||||
|
||||
const scheduledAt = new Date(res.data.nextReview.scheduledAt);
|
||||
const createdStudyLogId = res.data.studyLog.id;
|
||||
setRegisteredProblems((prev) => new Set(prev).add(problem.number));
|
||||
setRegisteredDates((prev) => ({ ...prev, [problem.number]: scheduledAt }));
|
||||
setTagPromptFor(createdStudyLogId);
|
||||
} catch {
|
||||
showToast({ message: '복습 등록에 실패했어.', variant: 'danger' });
|
||||
} finally {
|
||||
@@ -356,6 +360,14 @@ function UploadedEbookBody() {
|
||||
다음 <Icon name="caret-right" size={16} />
|
||||
</NavBtn>
|
||||
</Nav>
|
||||
|
||||
{tagPromptFor && (
|
||||
<TagPrompt
|
||||
studyLogId={tagPromptFor}
|
||||
onClose={() => setTagPromptFor(null)}
|
||||
onSaved={() => setTagPromptFor(null)}
|
||||
/>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ProblemImageStage,
|
||||
} from '@/components/exam/ProblemVisual';
|
||||
import DrawingCanvas, { PEN_COLORS, type PenColor } from '@/components/ui/DrawingCanvas';
|
||||
import TagPrompt from '@/components/ui/TagPrompt';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Button, KbdHint } from '@/components/ui/primitives';
|
||||
@@ -100,6 +101,7 @@ export default function ReviewPage() {
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
const [penColor, setPenColor] = useState<PenColor>('#ef4444');
|
||||
const [clearSignal, setClearSignal] = useState(0);
|
||||
const [tagPromptFor, setTagPromptFor] = useState<{ studyLogId: number; tagId: number | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -340,6 +342,11 @@ export default function ReviewPage() {
|
||||
},
|
||||
]);
|
||||
|
||||
// 태그가 없는 문제면 태그 추가 프롬프트
|
||||
if (action !== 'skip' && !item.studyLog.tag) {
|
||||
setTagPromptFor({ studyLogId: item.studyLogId, tagId: null });
|
||||
}
|
||||
|
||||
showToast({
|
||||
variant: 'success',
|
||||
message: `결과 기록됨 (${nextCompleted}/${progressTotal || nextCompleted})`,
|
||||
@@ -888,6 +895,15 @@ export default function ReviewPage() {
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tagPromptFor && (
|
||||
<TagPrompt
|
||||
studyLogId={tagPromptFor.studyLogId}
|
||||
currentTagId={tagPromptFor.tagId}
|
||||
onClose={() => setTagPromptFor(null)}
|
||||
onSaved={() => setTagPromptFor(null)}
|
||||
/>
|
||||
)}
|
||||
</Page>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,8 +257,13 @@ export default function ReviewCalendar({
|
||||
|
||||
{!dayLoading && dayReviews.length > 0 && (
|
||||
<ReviewList>
|
||||
{dayReviews.map((review) => (
|
||||
<ReviewItem key={review.id}>
|
||||
{[...dayReviews]
|
||||
.sort((a, b) => {
|
||||
const order: Record<string, number> = { pending: 0, skipped: 1, done: 2 };
|
||||
return (order[a.status] ?? 0) - (order[b.status] ?? 0);
|
||||
})
|
||||
.map((review) => (
|
||||
<ReviewItem key={review.id} $done={review.status === 'done'}>
|
||||
<ReviewMeta>
|
||||
{review.studyLog.tag && (
|
||||
<>
|
||||
@@ -271,7 +276,7 @@ export default function ReviewCalendar({
|
||||
)}
|
||||
<StatusBadge $status={review.status}>{statusLabel(review.status)}</StatusBadge>
|
||||
</ReviewMeta>
|
||||
<ReviewTitle>{review.studyLog.title}</ReviewTitle>
|
||||
<ReviewTitle $done={review.status === 'done'}>{review.studyLog.title}</ReviewTitle>
|
||||
{review.studyLog.problem?.bodyText && (
|
||||
<ReviewPreview>
|
||||
{review.studyLog.problem.bodyText.slice(0, 60)}
|
||||
@@ -301,7 +306,7 @@ export default function ReviewCalendar({
|
||||
</ReviewActions>
|
||||
</ReviewItem>
|
||||
))}
|
||||
</ReviewList>
|
||||
</ReviewList>
|
||||
)}
|
||||
</DayPanel>
|
||||
)}
|
||||
@@ -542,14 +547,15 @@ const ReviewList = styled.div`
|
||||
gap: ${theme.space.sm};
|
||||
`;
|
||||
|
||||
const ReviewItem = styled.div`
|
||||
const ReviewItem = styled.div<{ $done?: boolean }>`
|
||||
padding: ${theme.space.sm} ${theme.space.md};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: ${({ $done }) => ($done ? 'rgba(255, 255, 255, 0.01)' : 'rgba(255, 255, 255, 0.03)')};
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: ${theme.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
opacity: ${({ $done }) => ($done ? 0.55 : 1)};
|
||||
`;
|
||||
|
||||
const ReviewMeta = styled.div`
|
||||
@@ -594,14 +600,15 @@ const StatusBadge = styled.span<{ $status: string }>`
|
||||
: theme.color.warning};
|
||||
`;
|
||||
|
||||
const ReviewTitle = styled.p`
|
||||
const ReviewTitle = styled.p<{ $done?: boolean }>`
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${theme.color.textBright};
|
||||
color: ${({ $done }) => ($done ? theme.color.textMute : theme.color.textBright)};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: ${({ $done }) => ($done ? 'line-through' : 'none')};
|
||||
`;
|
||||
|
||||
const ReviewPreview = styled.p`
|
||||
|
||||
325
frontend/src/components/ui/TagPrompt.tsx
Normal file
325
frontend/src/components/ui/TagPrompt.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import { api, type Subject, type Tag } from '@/lib/api';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
interface TagPromptProps {
|
||||
/** 현재 studyLog ID — 태그 저장 대상 */
|
||||
studyLogId: number;
|
||||
/** 현재 이미 할당된 tagId (있으면 프롬프트 안 띄움) */
|
||||
currentTagId?: number | null;
|
||||
/** 닫기 */
|
||||
onClose: () => void;
|
||||
/** 태그 저장 성공 */
|
||||
onSaved?: (tag: { id: number; name: string }) => void;
|
||||
}
|
||||
|
||||
export default function TagPrompt({ studyLogId, currentTagId, onClose, onSaved }: TagPromptProps) {
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [selectedSubjectId, setSelectedSubjectId] = useState<number | ''>('');
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Subject[]>('/subjects').then((r) => {
|
||||
setSubjects(r.data);
|
||||
if (r.data[0]) {
|
||||
setSelectedSubjectId(r.data[0].id);
|
||||
setTags(r.data[0].tags ?? []);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSubjectChange = (subjectId: number) => {
|
||||
setSelectedSubjectId(subjectId);
|
||||
const subj = subjects.find((s) => s.id === subjectId);
|
||||
setTags(subj?.tags ?? []);
|
||||
};
|
||||
|
||||
const handleSelectTag = async (tagId: number) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.patch(`/study-logs/${studyLogId}`, { tagId });
|
||||
const tag = tags.find((t) => t.id === tagId);
|
||||
if (tag) onSaved?.(tag);
|
||||
onClose();
|
||||
} catch {
|
||||
// 실패 시 조용히
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
const name = newTagName.trim();
|
||||
if (!name || !selectedSubjectId) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await api.post<Tag>('/tags', { subjectId: selectedSubjectId, name });
|
||||
const newTag = res.data;
|
||||
setTags((prev) => [...prev, newTag]);
|
||||
setNewTagName('');
|
||||
// 바로 선택
|
||||
await handleSelectTag(newTag.id);
|
||||
} catch {
|
||||
// 중복 등 에러 무시
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay onClick={onClose}>
|
||||
<ModalCard onClick={(e) => e.stopPropagation()}>
|
||||
<Header>
|
||||
<HeaderTitle>
|
||||
<Icon name="folders" size={18} />
|
||||
이 문제에 태그를 추가할까?
|
||||
</HeaderTitle>
|
||||
<CloseBtn type="button" onClick={onClose}>
|
||||
<Icon name="x" size={16} />
|
||||
</CloseBtn>
|
||||
</Header>
|
||||
|
||||
<Hint>태그를 추가하면 단원별 숙련도 통계와 복습 알고리즘에 반영돼.</Hint>
|
||||
|
||||
{subjects.length > 1 && (
|
||||
<SubjectRow>
|
||||
<SmallLabel>과목</SmallLabel>
|
||||
<SubjectSelect
|
||||
value={selectedSubjectId}
|
||||
onChange={(e) => handleSubjectChange(Number(e.target.value))}
|
||||
>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</SubjectSelect>
|
||||
</SubjectRow>
|
||||
)}
|
||||
|
||||
<TagList>
|
||||
{tags.map((tag) => (
|
||||
<TagBtn
|
||||
key={tag.id}
|
||||
type="button"
|
||||
$selected={currentTagId === tag.id}
|
||||
disabled={saving}
|
||||
onClick={() => void handleSelectTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</TagBtn>
|
||||
))}
|
||||
{tags.length === 0 && (
|
||||
<EmptyMsg>아직 태그가 없어. 아래에서 새로 만들어봐!</EmptyMsg>
|
||||
)}
|
||||
</TagList>
|
||||
|
||||
<CreateRow>
|
||||
<CreateInput
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleCreateTag()}
|
||||
placeholder="새 태그 이름 (예: 미분, 확률)"
|
||||
maxLength={40}
|
||||
/>
|
||||
<CreateBtn
|
||||
type="button"
|
||||
disabled={!newTagName.trim() || creating}
|
||||
onClick={() => void handleCreateTag()}
|
||||
>
|
||||
<Icon name="plus" size={14} />
|
||||
{creating ? '...' : '추가'}
|
||||
</CreateBtn>
|
||||
</CreateRow>
|
||||
|
||||
<Footer>
|
||||
<SkipBtn type="button" onClick={onClose}>
|
||||
건너뛰기
|
||||
</SkipBtn>
|
||||
</Footer>
|
||||
</ModalCard>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────────
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
`;
|
||||
|
||||
const Overlay = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
`;
|
||||
|
||||
const ModalCard = styled.div`
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(21, 21, 28, 0.96);
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
animation: ${fadeIn} 0.15s ease;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const HeaderTitle = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: ${theme.color.textBright};
|
||||
`;
|
||||
|
||||
const CloseBtn = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${theme.color.textMute};
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
&:hover { color: ${theme.color.textBright}; }
|
||||
`;
|
||||
|
||||
const Hint = styled.p`
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textSub};
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const SubjectRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const SmallLabel = styled.span`
|
||||
font-size: 12px;
|
||||
color: ${theme.color.textSub};
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const SubjectSelect = styled.select`
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const TagList = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const TagBtn = styled.button<{ $selected: boolean }>`
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${({ $selected }) => ($selected ? 'rgba(129,140,248,0.5)' : theme.color.borderSoftAlpha)};
|
||||
background: ${({ $selected }) => ($selected ? 'rgba(79,70,229,0.15)' : 'rgba(255,255,255,0.03)')};
|
||||
color: ${({ $selected }) => ($selected ? '#a5b4fc' : theme.color.textBright)};
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
background: rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
const EmptyMsg = styled.span`
|
||||
font-size: 13px;
|
||||
color: ${theme.color.textMute};
|
||||
`;
|
||||
|
||||
const CreateRow = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const CreateInput = styled.input`
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${theme.color.borderSoftAlpha};
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: ${theme.color.textBright};
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
&:focus { border-color: ${theme.color.brandIndigo}; }
|
||||
&::placeholder { color: ${theme.color.textSub}; }
|
||||
`;
|
||||
|
||||
const CreateBtn = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.3);
|
||||
background: rgba(79, 70, 229, 0.12);
|
||||
color: #a5b4fc;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) { background: rgba(79, 70, 229, 0.2); }
|
||||
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const SkipBtn = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${theme.color.textMute};
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
&:hover { color: ${theme.color.textSub}; }
|
||||
`;
|
||||
Reference in New Issue
Block a user