feat: 캘린더 Quick Add — 날짜 클릭 후 바로 학습 기록 등록
DayPanel 헤더에 '+' 버튼 추가, 클릭 시 인라인 폼 펼침. 과목/태그/제목/결과/난이도 5필드로 투두리스트 스타일 간편 등록. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user