feat: 캘린더 Quick Add — 날짜 클릭 후 바로 학습 기록 등록

DayPanel 헤더에 '+' 버튼 추가, 클릭 시 인라인 폼 펼침.
과목/태그/제목/결과/난이도 5필드로 투두리스트 스타일 간편 등록.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-19 18:10:49 +09:00
parent eca67cfd07
commit b27d9a4240

View File

@@ -11,6 +11,8 @@ import {
getReviewDay,
type CalendarDay,
type DayReview,
type Subject,
type StudyResult,
} from '@/lib/api';
import { theme } from '@/styles/theme';
@@ -79,6 +81,78 @@ export default function ReviewCalendar({
const [dayReviews, setDayReviews] = useState<DayReview[]>([]);
const [dayLoading, setDayLoading] = useState(false);
// QuickAdd 상태
const [quickAddOpen, setQuickAddOpen] = useState(false);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [qaSubjectId, setQaSubjectId] = useState<number | ''>('');
const [qaTagId, setQaTagId] = useState<number | ''>('');
const [qaTitle, setQaTitle] = useState('');
const [qaResult, setQaResult] = useState<StudyResult>('correct');
const [qaDifficulty, setQaDifficulty] = useState<'hard' | 'medium' | 'easy'>('medium');
const [qaSaving, setQaSaving] = useState(false);
const [qaSuccessMsg, setQaSuccessMsg] = useState('');
const [qaError, setQaError] = useState('');
// subjects 한 번만 fetch
useEffect(() => {
api.get<Subject[]>('/subjects').then((res) => {
setSubjects(res.data);
}).catch(() => { /* 조용히 실패 */ });
}, []);
const selectedSubject = subjects.find((s) => s.id === qaSubjectId);
const availableTags = selectedSubject?.tags ?? [];
const resetQuickAdd = () => {
setQaSubjectId('');
setQaTagId('');
setQaTitle('');
setQaResult('correct');
setQaDifficulty('medium');
setQaSuccessMsg('');
setQaError('');
};
const handleQuickAddOpen = () => {
resetQuickAdd();
setQuickAddOpen(true);
};
const handleQuickAddCancel = () => {
setQuickAddOpen(false);
resetQuickAdd();
};
const handleQuickAddSave = async () => {
if (!qaSubjectId) { setQaError('과목을 선택해줘'); return; }
if (!qaTitle.trim()) { setQaError('문제 제목을 입력해줘'); return; }
const difficultyMap = { hard: 0.9, medium: 0.5, easy: 0.1 };
setQaSaving(true);
setQaError('');
try {
await api.post('/study-logs', {
subjectId: qaSubjectId,
tagId: qaTagId || undefined,
title: qaTitle.trim(),
difficulty: difficultyMap[qaDifficulty],
result: qaResult,
studiedAt: selectedDate,
});
setQaSuccessMsg('기록 완료!');
setTimeout(() => {
setQuickAddOpen(false);
resetQuickAdd();
}, 800);
void loadMonth(year, month);
} catch {
setQaError('저장에 실패했어. 다시 시도해줘.');
} finally {
setQaSaving(false);
}
};
const loadMonth = useCallback(async (y: number, m: number) => {
setLoading(true);
try {
@@ -233,11 +307,145 @@ export default function ReviewCalendar({
<DayPanelTitle>
{selectedDate.slice(5).replace('-', '월 ')}
</DayPanelTitle>
<CloseBtn onClick={() => { setSelectedDate(null); setDayReviews([]); }}>
<Icon name="x" size={14} weight="bold" color="currentColor" />
</CloseBtn>
<DayPanelActions>
<AddBtn
type="button"
onClick={handleQuickAddOpen}
aria-label="학습 기록 추가"
title="학습 기록 추가"
>
<Icon name="plus" size={14} weight="bold" color="currentColor" />
</AddBtn>
<CloseBtn onClick={() => { setSelectedDate(null); setDayReviews([]); setQuickAddOpen(false); resetQuickAdd(); }}>
<Icon name="x" size={14} weight="bold" color="currentColor" />
</CloseBtn>
</DayPanelActions>
</DayPanelHeader>
{/* QuickAdd 폼 */}
{quickAddOpen && (
<QuickAddWrap>
{subjects.length === 0 ? (
<PanelMsg> </PanelMsg>
) : (
<>
<QaRow>
<QaLabel></QaLabel>
<QaSelect
value={qaSubjectId}
onChange={(e) => {
setQaSubjectId(e.target.value ? Number(e.target.value) : '');
setQaTagId('');
}}
>
<option value=""> </option>
{subjects.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</QaSelect>
</QaRow>
{availableTags.length > 0 && (
<QaRow>
<QaLabel></QaLabel>
<QaSelect
value={qaTagId}
onChange={(e) => setQaTagId(e.target.value ? Number(e.target.value) : '')}
>
<option value=""> ()</option>
{availableTags.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</QaSelect>
</QaRow>
)}
<QaRow>
<QaLabel></QaLabel>
<QaInput
type="text"
value={qaTitle}
onChange={(e) => setQaTitle(e.target.value)}
placeholder="예) 2026 3월 모의고사 15번"
/>
</QaRow>
<QaRow>
<QaLabel></QaLabel>
<QaBtnGroup>
<QaToggleBtn
type="button"
$active={qaResult === 'correct'}
$color="success"
onClick={() => setQaResult('correct')}
>
</QaToggleBtn>
<QaToggleBtn
type="button"
$active={qaResult === 'partial'}
$color="warning"
onClick={() => setQaResult('partial')}
>
</QaToggleBtn>
<QaToggleBtn
type="button"
$active={qaResult === 'incorrect'}
$color="danger"
onClick={() => setQaResult('incorrect')}
>
</QaToggleBtn>
</QaBtnGroup>
</QaRow>
<QaRow>
<QaLabel></QaLabel>
<QaBtnGroup>
<QaToggleBtn
type="button"
$active={qaDifficulty === 'hard'}
$color="danger"
onClick={() => setQaDifficulty('hard')}
>
🔴
</QaToggleBtn>
<QaToggleBtn
type="button"
$active={qaDifficulty === 'medium'}
$color="warning"
onClick={() => setQaDifficulty('medium')}
>
🟡
</QaToggleBtn>
<QaToggleBtn
type="button"
$active={qaDifficulty === 'easy'}
$color="success"
onClick={() => setQaDifficulty('easy')}
>
🟢
</QaToggleBtn>
</QaBtnGroup>
</QaRow>
{qaError && <QaMsg $type="error">{qaError}</QaMsg>}
{qaSuccessMsg && <QaMsg $type="success">{qaSuccessMsg}</QaMsg>}
<QaFooter>
<QaSaveBtn type="button" onClick={() => void handleQuickAddSave()} disabled={qaSaving}>
{qaSaving ? '저장 중...' : '기록하기'}
</QaSaveBtn>
<QaCancelBtn type="button" onClick={handleQuickAddCancel} disabled={qaSaving}>
</QaCancelBtn>
</QaFooter>
</>
)}
</QuickAddWrap>
)}
{dayLoading && <PanelMsg> ...</PanelMsg>}
{!dayLoading && selectedDate > todayStr && dayReviews.length === 0 && (
@@ -641,3 +849,199 @@ const DeleteBtn = styled.button`
background: rgba(239, 68, 68, 0.1);
}
`;
// ─── QuickAdd 스타일 ──────────────────────────────────────────────────────────
const DayPanelActions = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.xs};
`;
const AddBtn = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid ${theme.color.borderSoftAlpha};
background: transparent;
color: ${theme.color.textMute};
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
&:hover {
background: rgba(79, 70, 229, 0.15);
color: ${theme.color.brandIndigo};
border-color: ${theme.color.brandIndigo};
}
`;
const QuickAddWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
background: rgba(255, 255, 255, 0.03);
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: ${theme.radius.md};
padding: ${theme.space.md};
`;
const QaRow = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const QaLabel = styled.label`
font-size: 11px;
font-weight: 600;
color: ${theme.color.textMute};
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const QaSelect = styled.select`
background: rgba(255, 255, 255, 0.04);
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: ${theme.radius.sm};
color: ${theme.color.textBright};
font-size: 13px;
padding: 6px 10px;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
&:focus {
border-color: ${theme.color.brandIndigo};
}
option {
background: #1a1a24;
color: ${theme.color.textBright};
}
`;
const QaInput = styled.input`
background: rgba(255, 255, 255, 0.04);
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: ${theme.radius.sm};
color: ${theme.color.textBright};
font-size: 13px;
padding: 6px 10px;
outline: none;
transition: border-color 0.15s;
&::placeholder {
color: ${theme.color.textMute};
}
&:focus {
border-color: ${theme.color.brandIndigo};
}
`;
const QaBtnGroup = styled.div`
display: flex;
gap: 6px;
`;
const QaToggleBtn = styled.button<{ $active: boolean; $color: 'success' | 'warning' | 'danger' }>`
flex: 1;
padding: 5px 8px;
border-radius: ${theme.radius.sm};
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
border: 1px solid
${({ $active, $color }) => {
if (!$active) return theme.color.borderSoftAlpha;
if ($color === 'success') return 'rgba(34, 197, 94, 0.5)';
if ($color === 'warning') return 'rgba(245, 158, 11, 0.5)';
return 'rgba(239, 68, 68, 0.5)';
}};
background: ${({ $active, $color }) => {
if (!$active) return 'transparent';
if ($color === 'success') return 'rgba(34, 197, 94, 0.15)';
if ($color === 'warning') return 'rgba(245, 158, 11, 0.15)';
return 'rgba(239, 68, 68, 0.15)';
}};
color: ${({ $active, $color }) => {
if (!$active) return theme.color.textMute;
if ($color === 'success') return theme.color.success;
if ($color === 'warning') return theme.color.warning;
return theme.color.danger;
}};
&:hover {
background: ${({ $color }) => {
if ($color === 'success') return 'rgba(34, 197, 94, 0.12)';
if ($color === 'warning') return 'rgba(245, 158, 11, 0.12)';
return 'rgba(239, 68, 68, 0.12)';
}};
color: ${({ $color }) => {
if ($color === 'success') return theme.color.success;
if ($color === 'warning') return theme.color.warning;
return theme.color.danger;
}};
}
`;
const QaMsg = styled.p<{ $type: 'error' | 'success' }>`
margin: 0;
font-size: 12px;
font-weight: 500;
color: ${({ $type }) => ($type === 'error' ? theme.color.danger : theme.color.success)};
`;
const QaFooter = styled.div`
display: flex;
gap: ${theme.space.xs};
margin-top: 4px;
`;
const QaSaveBtn = styled.button`
flex: 1;
padding: 7px 12px;
border-radius: ${theme.radius.sm};
border: none;
background: ${theme.color.brandIndigo};
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
&:hover:not(:disabled) {
opacity: 0.85;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const QaCancelBtn = styled.button`
padding: 7px 12px;
border-radius: ${theme.radius.sm};
border: 1px solid ${theme.color.borderSoftAlpha};
background: transparent;
color: ${theme.color.textMute};
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover:not(:disabled) {
background: ${theme.color.surfaceHoverDeep};
color: ${theme.color.textSub};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;