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:
reloop
2026-04-17 17:17:06 +09:00
parent b975889c2e
commit 85caf9b71d
6 changed files with 377 additions and 8 deletions

View File

@@ -221,6 +221,11 @@ class UpdateStudyLogDto implements UpdateStudyLogInput {
@IsOptional()
@IsString()
memo?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
tagId?: number | null;
}
@Controller('study-logs')

View File

@@ -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 } },
},
});
}

View File

@@ -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>
);
}

View File

@@ -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>,
);
}

View File

@@ -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`

View 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}; }
`;