feat: 복습 문제 삭제 기능 (잘못 누른 경우 대응)
- backend: DELETE /reviews/:id 엔드포인트 + 소유권 확인 후 hard delete - 캘린더 패널: 각 복습 항목 옆 빨간 "삭제" 버튼, confirm 후 낙관적 제거 + 셀 카운트 갱신 - 복습 풀이 큐: ActionGrid 하단 "이 문제 삭제" ghost 버튼, 삭제 후 다음 문제로 전환 - pending/done/skipped 상태 무관하게 삭제 허용
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user