feat: 복습 문제 삭제 기능 (잘못 누른 경우 대응)

- backend: DELETE /reviews/:id 엔드포인트 + 소유권 확인 후 hard delete
- 캘린더 패널: 각 복습 항목 옆 빨간 "삭제" 버튼, confirm 후 낙관적 제거 + 셀 카운트 갱신
- 복습 풀이 큐: ActionGrid 하단 "이 문제 삭제" ghost 버튼, 삭제 후 다음 문제로 전환
- pending/done/skipped 상태 무관하게 삭제 허용
This commit is contained in:
reloop
2026-04-16 11:59:14 +09:00
parent 07be0c581a
commit 21c31a07dc
4 changed files with 162 additions and 8 deletions

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
@@ -76,6 +77,14 @@ export class ReviewsController {
return this.svc.skip(user.id, id);
}
@Delete(':id')
remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
) {
return this.svc.remove(user.id, id);
}
@Get('history')
history(@CurrentUser() user: AuthUser, @Query() q: HistoryQuery) {
return this.svc.history(user.id, q.limit);

View File

@@ -171,6 +171,17 @@ export class ReviewsService {
});
}
async remove(userId: number, reviewId: number) {
const review = await this.prisma.reviewSchedule.findUnique({
where: { id: reviewId },
});
if (!review) throw new NotFoundException();
if (review.userId !== userId) throw new ForbiddenException();
await this.prisma.reviewSchedule.delete({ where: { id: reviewId } });
return { deleted: true };
}
async history(userId: number, limit = 50) {
return this.prisma.reviewSchedule.findMany({
where: {

View File

@@ -242,6 +242,35 @@ export default function ReviewPage() {
}
}, [history, pending, showToast]);
const handleDelete = useCallback(
async (item: QueueItem) => {
if (pending.has(item.id) || !queue) return;
if (!window.confirm('이 복습 문제를 삭제할까? 삭제하면 다음 복습도 생성되지 않아.')) return;
setPending((prev) => new Set(prev).add(item.id));
try {
await api.delete(`/reviews/${item.id}`);
const nextQueue = queue.filter((candidate) => candidate.id !== item.id);
setQueue(nextQueue);
} catch {
showToast({
variant: 'danger',
message: '삭제에 실패했어요.',
durationMs: 2000,
});
} finally {
setPending((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
}
},
[pending, queue, showToast],
);
const handleResult = useCallback(
async (item: QueueItem, action: ReviewAction) => {
if (pending.has(item.id) || !queue) return;
@@ -648,6 +677,7 @@ export default function ReviewPage() {
</StackStage>
{currentItem && (
<>
<ActionGrid>
<ActionButton
type="button"
@@ -713,6 +743,17 @@ export default function ReviewPage() {
<ActionShortcut>S</ActionShortcut>
</ActionButton>
</ActionGrid>
<DeleteRowWrap>
<ReviewDeleteBtn
type="button"
disabled={pending.has(currentItem.id)}
onClick={() => void handleDelete(currentItem)}
>
</ReviewDeleteBtn>
</DeleteRowWrap>
</>
)}
</Content>
@@ -1565,6 +1606,34 @@ const actionToneStyles = {
`,
} as const;
const DeleteRowWrap = styled.div`
display: flex;
justify-content: center;
margin-top: 8px;
`;
const ReviewDeleteBtn = styled.button`
background: transparent;
border: none;
padding: 6px 12px;
border-radius: ${theme.radius.sm};
font-size: 12px;
color: ${theme.color.danger};
cursor: pointer;
opacity: 0.7;
transition: opacity 0.15s, background 0.15s;
&:hover:not(:disabled) {
opacity: 1;
background: rgba(239, 68, 68, 0.1);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
`;
function hexToRgba(hex: string, alpha: number): string {
const clean = hex.replace('#', '');
const normalized =

View File

@@ -6,6 +6,7 @@ import styled from 'styled-components';
import { Icon } from '@/components/ui/Icon';
import { Button } from '@/components/ui/primitives';
import {
api,
getReviewCalendar,
getReviewDay,
type CalendarDay,
@@ -109,6 +110,35 @@ export default function ReviewCalendar({
setYear(y);
};
const handleDeleteReview = async (reviewId: number, scheduledDate: string) => {
if (!window.confirm('이 복습 문제를 삭제할까? 삭제하면 다음 복습도 생성되지 않아.')) return;
try {
await api.delete(`/reviews/${reviewId}`);
// 목록에서 즉시 제거 (낙관적 업데이트)
setDayReviews((prev) => prev.filter((r) => r.id !== reviewId));
// 캘린더 날짜 셀 카운트 갱신
setDayMap((prev) => {
const next = new Map(prev);
const existing = next.get(scheduledDate);
if (existing) {
const newTotal = existing.total - 1;
if (newTotal <= 0) {
next.delete(scheduledDate);
} else {
next.set(scheduledDate, { ...existing, total: newTotal });
}
}
return next;
});
} catch {
// 실패 시 조용히 무시 (UI는 이미 낙관적으로 제거됨 — 재로드로 복구)
void loadMonth(year, month);
}
};
const handleDateClick = async (dateKey: string) => {
if (selectedDate === dateKey) {
setSelectedDate(null);
@@ -248,14 +278,27 @@ export default function ReviewCalendar({
{review.studyLog.problem.bodyText.length > 60 ? '…' : ''}
</ReviewPreview>
)}
{selectedDate <= todayStr && review.status === 'pending' && (
<Link href={`/review`} passHref>
<Button as="span" $variant="white" style={{ marginTop: 8, fontSize: 13 }}>
<Icon name="arrow-right" size={13} weight="bold" />
</Button>
</Link>
)}
<ReviewActions>
{selectedDate <= todayStr && review.status === 'pending' && (
<Link href={`/review`} passHref>
<Button as="span" $variant="white" style={{ fontSize: 13 }}>
<Icon name="arrow-right" size={13} weight="bold" />
</Button>
</Link>
)}
<DeleteBtn
type="button"
onClick={() =>
void handleDeleteReview(
review.id,
review.scheduledAt.slice(0, 10),
)
}
>
</DeleteBtn>
</ReviewActions>
</ReviewItem>
))}
</ReviewList>
@@ -569,3 +612,25 @@ const ReviewPreview = styled.p`
overflow: hidden;
text-overflow: ellipsis;
`;
const ReviewActions = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.xs};
margin-top: 8px;
`;
const DeleteBtn = styled.button`
background: transparent;
border: none;
padding: 4px 6px;
border-radius: ${theme.radius.sm};
font-size: 12px;
color: ${theme.color.danger};
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(239, 68, 68, 0.1);
}
`;