diff --git a/src/components/admin/courses/admin-course-form-sections.tsx b/src/components/admin/courses/admin-course-form-sections.tsx index 93f58aeeb..af8af7e0b 100644 --- a/src/components/admin/courses/admin-course-form-sections.tsx +++ b/src/components/admin/courses/admin-course-form-sections.tsx @@ -138,20 +138,6 @@ export function AdminCourseFormContent({ } /> - -
-

- 자동 계산 항목 -

-

- 카드 인원, 학습 수, 추천 수, 탐색 수, 가격/얼리버드 값은 현재 API - 요청 본문에 포함하지 않습니다. -

-
-
updateCourseFormField('cardTags', cardTags)} /> -
-

- 자동 계산 항목 -

-

- 카드 인원, 학습 수, 추천 수, 탐색 수는 서버에서 자동 계산됩니다. -

-
{ @@ -54,18 +69,7 @@ const toKstOffsetDateTime = (value: string) => { return `${value.length === 16 ? `${value}:00` : value}+09:00`; }; -const serializePlanItems = (items: AdminCoursePlan['items']) => { - return items - .map((item, index) => - [ - item.itemCode ?? '', - item.label, - item.valueAmount ?? 0, - item.displayOrder ?? index, - ].join(' | '), - ) - .join('\n'); -}; +const isDigitsOnly = (value: string) => /^\d+$/.test(value); const toPlanFormValues = (plan: AdminCoursePlan): PlanFormValues => ({ planCode: plan.planCode, @@ -78,44 +82,65 @@ const toPlanFormValues = (plan: AdminCoursePlan): PlanFormValues => ({ isActive: plan.isActive ? 'true' : 'false', isRecommended: plan.isRecommended ? 'true' : 'false', displayOrder: String(plan.displayOrder), - itemsText: serializePlanItems(plan.items), + items: plan.items.map((item) => ({ + itemCode: item.itemCode ?? '', + label: item.label, + valueAmount: + item.valueAmount === null || item.valueAmount === undefined + ? '' + : String(item.valueAmount), + displayOrder: + item.displayOrder === null || item.displayOrder === undefined + ? '' + : String(item.displayOrder), + })), }); -const parseRequiredNumber = (value: string, fieldName: string) => { - const parsed = Number(value.trim()); - if (!Number.isFinite(parsed)) { +const parseRequiredPositiveNumber = (value: string, fieldName: string) => { + const trimmed = value.trim(); + const parsed = Number(trimmed); + if (!isDigitsOnly(trimmed) || !Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} 양수 숫자를 입력해주세요.`); + } + return parsed; +}; + +const parseRequiredNonNegativeNumber = (value: string, fieldName: string) => { + const trimmed = value.trim(); + const parsed = Number(trimmed); + if (!isDigitsOnly(trimmed) || !Number.isFinite(parsed) || parsed < 0) { throw new Error(`${fieldName} 숫자를 입력해주세요.`); } return parsed; }; +const parseOptionalNonNegativeNumber = (value: string, fieldName: string) => { + const trimmed = value.trim(); + if (!trimmed) return null; + return parseRequiredNonNegativeNumber(trimmed, fieldName); +}; + const parsePlanItems = ( - itemsText: string, + items: PlanItemFormValue[], ): AdminCoursePlanItemUpsertRequest[] => - itemsText - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line, index) => { - const [itemCode, label, valueAmount, displayOrder] = line - .split('|') - .map((part) => part.trim()); - - if (!label) { - throw new Error('플랜 구성 항목 label을 입력해주세요.'); - } - - return { - itemCode: itemCode || null, - label, - valueAmount: valueAmount - ? parseRequiredNumber(valueAmount, '항목 금액') - : 0, - displayOrder: displayOrder - ? parseRequiredNumber(displayOrder, '항목 순서') - : index, - }; - }); + items.map((item, index) => { + const label = item.label.trim(); + + if (!label) { + throw new Error(`구성 항목 ${index + 1}의 항목명을 입력해주세요.`); + } + + return { + itemCode: item.itemCode.trim() || null, + label, + valueAmount: parseOptionalNonNegativeNumber( + item.valueAmount, + '항목 금액', + ), + displayOrder: + parseOptionalNonNegativeNumber(item.displayOrder, '항목 순서') ?? index, + }; + }); const showValidationError = (error: unknown) => { if (error instanceof Error) { @@ -128,18 +153,31 @@ const toPlanPayload = (form: PlanFormValues): AdminCoursePlanUpsertRequest => { throw new Error('플랜 코드와 이름을 입력해주세요.'); } + const regularPrice = parseRequiredPositiveNumber(form.regularPrice, '정가'); + const discountPrice = parseRequiredPositiveNumber( + form.discountPrice, + '얼리버드 할인가', + ); + + if (discountPrice > regularPrice) { + throw new Error('얼리버드 할인가는 정가보다 클 수 없습니다.'); + } + return { planCode: form.planCode.trim(), name: form.name.trim(), subtitle: form.subtitle.trim() || null, description: form.description.trim() || null, - regularPrice: parseRequiredNumber(form.regularPrice, '정가'), - discountPrice: parseRequiredNumber(form.discountPrice, '할인가'), + regularPrice, + discountPrice, earlyBirdEndsAt: toKstOffsetDateTime(form.earlyBirdEndsAt), isActive: form.isActive === 'true', isRecommended: form.isRecommended === 'true', - displayOrder: parseRequiredNumber(form.displayOrder, '노출 순서'), - items: parsePlanItems(form.itemsText), + displayOrder: parseRequiredNonNegativeNumber( + form.displayOrder, + '노출 순서', + ), + items: parsePlanItems(form.items), }; }; @@ -193,9 +231,31 @@ export default function AdminCoursePlanManagement({ })); }; + const hasRecommendedPlanConflict = ( + targetForm: PlanFormValues, + targetPlanId?: number, + ) => { + if (targetForm.isRecommended !== 'true') return false; + + return ( + plansQuery.data?.some((plan) => { + if (plan.planId === targetPlanId) return false; + const planForm = editForms[plan.planId] ?? toPlanFormValues(plan); + return planForm.isRecommended === 'true'; + }) ?? false + ); + }; + const handleCreatePlan = async () => { let request: AdminCoursePlanUpsertRequest; + if (hasRecommendedPlanConflict(createForm)) { + useToastStore + .getState() + .showToast('대표 플랜은 코스당 하나만 저장할 수 있습니다.', 'info'); + return; + } + try { request = toPlanPayload(createForm); } catch (error) { @@ -216,6 +276,13 @@ export default function AdminCoursePlanManagement({ let request: AdminCoursePlanUpsertRequest; + if (hasRecommendedPlanConflict(form, planId)) { + useToastStore + .getState() + .showToast('대표 플랜은 코스당 하나만 저장할 수 있습니다.', 'info'); + return; + } + try { request = toPlanPayload(form); } catch (error) { @@ -311,14 +378,19 @@ function PlanEditor({

{title}

{onDeactivate && ( - +
+

+ 신규 결제에서 제외되며 기존 결제에는 영향이 없습니다. +

+ +
)}
-