feat: 2022 개정 교육과정 수학 프리셋 — 과목 생성 시 단원 자동 배치

- backend: curriculum-presets.ts에 중1~고3 + 공통수학/대수/미적분/확통/기하 단원 데이터
- backend: subjects.create() 시 과목명이 프리셋에 해당하면 태그 자동 createMany
- frontend: 과목 생성 모달에 교육과정 프리셋 칩 13개 추가
  (중1수학~고3수학, 공통수학1/2, 대수, 미적분I/II, 확통, 기하)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-19 16:51:33 +09:00
parent aa3ab3a1a7
commit 5db1520e67
3 changed files with 336 additions and 3 deletions

View File

@@ -0,0 +1,244 @@
/**
* 2022 개정 교육과정 수학 단원 프리셋
* 과목명(예: "중1수학")을 입력하면 해당 단원이 태그로 자동 생성됨
*/
export const CURRICULUM_PRESETS: Record<string, string[]> = {
// ── 중학교 ──────────────────────────────────────────────────────
1: [
'소인수분해',
'정수와 유리수',
'문자와 식',
'일차방정식',
'좌표평면과 그래프',
'기본 도형',
'평면도형의 성질',
'입체도형의 성질',
'자료의 정리와 해석',
],
2: [
'수와 식의 계산',
'일차부등식',
'연립일차방정식',
'일차함수와 그래프',
'도형의 성질',
'도형의 닮음',
'피타고라스 정리',
'확률',
],
3: [
'실수와 그 계산',
'다항식의 곱셈과 인수분해',
'이차방정식',
'이차함수와 그래프',
'삼각비',
'원의 성질',
'통계',
],
// ── 고등학교 공통 ───────────────────────────────────────────────
'공통수학1': [
'다항식의 연산',
'나머지정리와 인수분해',
'복소수',
'이차방정식',
'이차함수와 이차방정식',
'고차방정식',
'부등식과 이차부등식',
'경우의 수',
'순열과 조합',
'행렬',
],
'공통수학2': [
'직선의 방정식',
'원의 방정식',
'평행이동과 대칭이동',
'집합',
'명제와 조건',
'함수',
'합성함수와 역함수',
'유리함수와 무리함수',
],
1: [
// 공통수학1+2 통합
'다항식의 연산',
'나머지정리와 인수분해',
'복소수',
'이차방정식',
'이차함수와 이차방정식',
'고차방정식',
'부등식과 이차부등식',
'경우의 수',
'순열과 조합',
'행렬',
'직선의 방정식',
'원의 방정식',
'평행이동과 대칭이동',
'집합',
'명제와 조건',
'함수',
'합성함수와 역함수',
'유리함수와 무리함수',
],
// ── 고등학교 일반선택 ───────────────────────────────────────────
: [
'지수',
'로그',
'지수함수',
'로그함수',
'삼각함수',
'삼각함수의 그래프',
'삼각함수의 활용',
'등차수열과 등비수열',
'수열의 합',
'수학적 귀납법',
],
'미적분I': [
'함수의 극한',
'함수의 연속',
'미분계수와 도함수',
'접선의 방정식',
'함수의 증가와 감소',
'함수의 극대와 극소',
'최댓값과 최솟값',
'부정적분',
'정적분',
'정적분의 활용',
],
1: [
'함수의 극한',
'함수의 연속',
'미분계수와 도함수',
'접선의 방정식',
'함수의 증가와 감소',
'함수의 극대와 극소',
'최댓값과 최솟값',
'부정적분',
'정적분',
'정적분의 활용',
],
'확률과 통계': [
'순열과 조합',
'이항정리',
'확률의 뜻과 활용',
'조건부확률',
'독립시행의 확률',
'확률변수와 확률분포',
'이항분포',
'정규분포',
'통계적 추정',
],
: [
'순열과 조합',
'이항정리',
'확률의 뜻과 활용',
'조건부확률',
'독립시행의 확률',
'확률변수와 확률분포',
'이항분포',
'정규분포',
'통계적 추정',
],
2: [
// 대수 + 미적분I + 확통 통합
'지수',
'로그',
'지수함수',
'로그함수',
'삼각함수',
'삼각함수의 그래프',
'등차수열과 등비수열',
'수열의 합',
'함수의 극한',
'함수의 연속',
'미분계수와 도함수',
'함수의 극대와 극소',
'부정적분',
'정적분',
'확률',
'이항분포와 정규분포',
'통계적 추정',
],
// ── 고등학교 진로선택 ───────────────────────────────────────────
'미적분II': [
'수열의 극한',
'급수',
'지수함수와 로그함수의 미분',
'삼각함수의 미분',
'합성함수의 미분법',
'음함수와 역함수의 미분',
'이계도함수',
'도함수의 활용',
'치환적분법',
'부분적분법',
'정적분의 활용',
],
2: [
'수열의 극한',
'급수',
'지수함수와 로그함수의 미분',
'삼각함수의 미분',
'합성함수의 미분법',
'음함수와 역함수의 미분',
'이계도함수',
'도함수의 활용',
'치환적분법',
'부분적분법',
'정적분의 활용',
],
: [
'포물선',
'타원',
'쌍곡선',
'이차곡선의 접선',
'공간도형',
'공간좌표',
'벡터의 연산',
'벡터의 성분과 내적',
],
3: [
// 미적분II + 기하 통합
'수열의 극한',
'급수',
'지수함수와 로그함수의 미분',
'삼각함수의 미분',
'합성함수의 미분법',
'도함수의 활용',
'치환적분법',
'부분적분법',
'정적분의 활용',
'포물선',
'타원',
'쌍곡선',
'이차곡선의 접선',
'공간도형과 공간좌표',
'벡터의 연산',
'벡터의 내적',
],
};
/**
* 과목명에서 프리셋 단원 목록을 찾는다.
* 정확한 키 매칭 또는 공백/대소문자 무시 매칭
*/
export function findPresetUnits(subjectName: string): string[] | null {
const trimmed = subjectName.trim();
// 정확한 키 매칭
if (CURRICULUM_PRESETS[trimmed]) {
return CURRICULUM_PRESETS[trimmed];
}
// 공백/특수문자 제거 후 매칭
const normalized = trimmed.replace(/[\s\-_·]/g, '').toLowerCase();
for (const [key, units] of Object.entries(CURRICULUM_PRESETS)) {
const normalizedKey = key.replace(/[\s\-_·]/g, '').toLowerCase();
if (normalizedKey === normalized) {
return units;
}
}
return null;
}

View File

@@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { findPresetUnits } from './curriculum-presets';
@Injectable()
export class SubjectsService {
@@ -22,10 +23,27 @@ export class SubjectsService {
return subject;
}
create(userId: number, name: string, color: string) {
return this.prisma.subject.create({
async create(userId: number, name: string, color: string) {
const subject = await this.prisma.subject.create({
data: { userId, name, color },
});
// 교육과정 프리셋 단원 자동 생성
const presetUnits = findPresetUnits(name);
if (presetUnits && presetUnits.length > 0) {
await this.prisma.tag.createMany({
data: presetUnits.map((unitName) => ({
subjectId: subject.id,
name: unitName,
})),
skipDuplicates: true,
});
}
return this.prisma.subject.findUniqueOrThrow({
where: { id: subject.id },
include: { tags: { orderBy: { name: 'asc' } } },
});
}
async update(userId: number, id: number, data: { name?: string; color?: string }) {

View File

@@ -11,6 +11,22 @@ import { Button, Input, Label } from '@/components/ui/primitives';
import { api, type Subject, type Tag } from '@/lib/api';
import { animations, theme } from '@/styles/theme';
const MATH_PRESETS = [
{ name: '중1수학', color: '#3b82f6' },
{ name: '중2수학', color: '#0EA5E9' },
{ name: '중3수학', color: '#06B6D4' },
{ name: '공통수학1', color: '#6366f1' },
{ name: '공통수학2', color: '#8B5CF6' },
{ name: '고1수학', color: '#6366f1' },
{ name: '대수', color: '#7C3AED' },
{ name: '미적분I', color: '#EC4899' },
{ name: '확률과 통계', color: '#F59E0B' },
{ name: '고2수학', color: '#7C3AED' },
{ name: '미적분II', color: '#EF4444' },
{ name: '기하', color: '#10B981' },
{ name: '고3수학', color: '#EF4444' },
] as const;
const PRESET_COLORS = [
theme.color.brandIndigo,
theme.color.brandViolet,
@@ -940,9 +956,26 @@ function SubjectModal({
onChange={(event) =>
onChange({ ...value, name: event.target.value })
}
placeholder="예) 공통 수학, 미적분"
placeholder="예) 중1수학, 공통수학1, 대수"
maxLength={40}
/>
{mode === 'create' && (
<PresetSection>
<PresetLabel> ( )</PresetLabel>
<PresetChips>
{MATH_PRESETS.map(({ name, color }) => (
<PresetChip
key={name}
type="button"
$active={value.name === name}
onClick={() => onChange({ ...value, name, color })}
>
{name}
</PresetChip>
))}
</PresetChips>
</PresetSection>
)}
</div>
<div>
@@ -1549,6 +1582,44 @@ const FieldGroup = styled.div`
gap: 18px;
`;
const PresetSection = styled.div`
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
`;
const PresetLabel = styled.span`
font-size: 12px;
color: ${theme.color.textMute};
`;
const PresetChips = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const PresetChip = styled.button<{ $active: boolean }>`
padding: 5px 12px;
border-radius: ${theme.radius.pill};
border: 1px solid
${({ $active }) =>
$active ? 'rgba(129, 140, 248, 0.6)' : theme.color.borderSoftAlpha};
background: ${({ $active }) =>
$active ? 'rgba(79, 70, 229, 0.18)' : 'rgba(255, 255, 255, 0.03)'};
color: ${({ $active }) =>
$active ? theme.color.textBright : theme.color.textSub};
font-size: 12px;
font-weight: 600;
transition: all 0.12s ease;
&:hover {
border-color: rgba(129, 140, 248, 0.4);
color: ${theme.color.textBright};
}
`;
const PickerWrap = styled.div`
display: flex;
flex-direction: column;