diff --git a/src/app/admin/blog/magnet/[id]/form/common/page.tsx b/src/app/admin/blog/magnet/[id]/form/common/page.tsx index e8abae238..ced262b00 100644 --- a/src/app/admin/blog/magnet/[id]/form/common/page.tsx +++ b/src/app/admin/blog/magnet/[id]/form/common/page.tsx @@ -1,4 +1,10 @@ -// TODO: 공통 신청폼 관리 페이지 구현 예정 -export default function MagnetFormCommonPage() { - return
공통 신청폼 관리 (준비 중)
; -} +import CommonFormPage from '@/domain/admin/blog/magnet/CommonFormPage'; +import { fetchCommonForm } from '@/domain/admin/blog/magnet/mock'; + +const Page = async () => { + const initialData = await fetchCommonForm(); + + return ; +}; + +export default Page; diff --git a/src/app/admin/blog/magnet/[id]/form/page.tsx b/src/app/admin/blog/magnet/[id]/form/page.tsx index 2104a12c4..d8e88255a 100644 --- a/src/app/admin/blog/magnet/[id]/form/page.tsx +++ b/src/app/admin/blog/magnet/[id]/form/page.tsx @@ -1,4 +1,11 @@ -// TODO: 마그넷 신청 폼 관리 페이지 구현 예정 -export default function MagnetFormPage() { - return
신청 폼 관리 (준비 중)
; -} +import { fetchMagnetForm } from '@/domain/admin/blog/magnet/mock'; +import MagnetFormPage from '@/domain/admin/blog/magnet/MagnetFormPage'; + +const Page = async ({ params }: { params: Promise<{ id: string }> }) => { + const { id } = await params; + const initialData = await fetchMagnetForm(Number(id)); + + return ; +}; + +export default Page; diff --git a/src/domain/admin/blog/magnet/CommonFormPage.tsx b/src/domain/admin/blog/magnet/CommonFormPage.tsx new file mode 100644 index 000000000..72683da52 --- /dev/null +++ b/src/domain/admin/blog/magnet/CommonFormPage.tsx @@ -0,0 +1,63 @@ +'use client'; + +import FormBuilderSection from '@/domain/admin/blog/magnet/form/FormBuilderSection'; +import { useCommonFormBuilder } from '@/domain/admin/blog/magnet/hooks/useCommonFormBuilder'; +import { CommonFormData } from '@/domain/admin/blog/magnet/types'; +import Heading from '@/domain/admin/ui/heading/Heading'; +import { Button, IconButton } from '@mui/material'; +import { ArrowLeft } from 'lucide-react'; + +interface CommonFormPageProps { + initialData: CommonFormData; +} + +const CommonFormPage = ({ initialData }: CommonFormPageProps) => { + const { + questions, + addQuestion, + removeQuestion, + updateQuestion, + saveForm, + navigateToList, + } = useCommonFormBuilder({ initialData }); + + return ( +
+
+ + + + 공통 신청폼 관리 +
+ +
+ + +
+ + +
+
+
+ ); +}; + +export default CommonFormPage; diff --git a/src/domain/admin/blog/magnet/MagnetFormPage.tsx b/src/domain/admin/blog/magnet/MagnetFormPage.tsx new file mode 100644 index 000000000..c6b81e886 --- /dev/null +++ b/src/domain/admin/blog/magnet/MagnetFormPage.tsx @@ -0,0 +1,70 @@ +'use client'; + +import CloneFormDropdown from '@/domain/admin/blog/magnet/form/CloneFormDropdown'; +import FormBuilderSection from '@/domain/admin/blog/magnet/form/FormBuilderSection'; +import { useMagnetFormBuilder } from '@/domain/admin/blog/magnet/hooks/useMagnetFormBuilder'; +import { MagnetFormData } from '@/domain/admin/blog/magnet/types'; +import Heading from '@/domain/admin/ui/heading/Heading'; +import { Button } from '@mui/material'; + +interface MagnetFormPageProps { + magnetId: string; + initialData: MagnetFormData; +} + +const MagnetFormPage = ({ + magnetId, + initialData, +}: MagnetFormPageProps) => { + const { + questions, + addQuestion, + removeQuestion, + updateQuestion, + cloneFromMagnet, + saveForm, + navigateToList, + } = useMagnetFormBuilder({ magnetId, initialData }); + + return ( +
+
+ 신청폼 관리 + 0} + onClone={cloneFromMagnet} + /> +
+ +
+ + +
+ + +
+
+
+ ); +}; + +export default MagnetFormPage; diff --git a/src/domain/admin/blog/magnet/form/CloneFormDropdown.tsx b/src/domain/admin/blog/magnet/form/CloneFormDropdown.tsx new file mode 100644 index 000000000..37484b5bd --- /dev/null +++ b/src/domain/admin/blog/magnet/form/CloneFormDropdown.tsx @@ -0,0 +1,82 @@ +import { + fetchMagnetForm, + fetchMagnetsWithForm, +} from '@/domain/admin/blog/magnet/mock'; +import { FormQuestion } from '@/domain/admin/blog/magnet/types'; +import { Button, Menu, MenuItem } from '@mui/material'; +import { Copy } from 'lucide-react'; +import { MouseEvent, useMemo, useState } from 'react'; + +interface CloneFormDropdownProps { + currentMagnetId: number; + hasExistingQuestions: boolean; + onClone: (questions: FormQuestion[]) => void; +} + +const CloneFormDropdown = ({ + currentMagnetId, + hasExistingQuestions, + onClone, +}: CloneFormDropdownProps) => { + const [anchorEl, setAnchorEl] = useState(null); + + const magnetsWithForm = useMemo(() => { + return fetchMagnetsWithForm().filter( + (m) => m.id !== currentMagnetId, + ); + }, [currentMagnetId]); + + const handleOpen = (e: MouseEvent) => { + setAnchorEl(e.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleClone = async (magnetId: number) => { + handleClose(); + + if (hasExistingQuestions) { + const confirmed = window.confirm( + '기존 질문이 모두 대체됩니다. 계속하시겠습니까?', + ); + if (!confirmed) return; + } + + const data = await fetchMagnetForm(magnetId); + onClone(data.questions); + }; + + return ( + <> + + + {magnetsWithForm.length === 0 ? ( + 복제할 수 있는 폼이 없습니다 + ) : ( + magnetsWithForm.map((m) => ( + handleClone(m.id)} + > + [{m.id}] {m.title} ({m.questionCount}개 질문) + + )) + )} + + + ); +}; + +export default CloneFormDropdown; diff --git a/src/domain/admin/blog/magnet/form/FormBuilderSection.tsx b/src/domain/admin/blog/magnet/form/FormBuilderSection.tsx new file mode 100644 index 000000000..5bb4d9622 --- /dev/null +++ b/src/domain/admin/blog/magnet/form/FormBuilderSection.tsx @@ -0,0 +1,51 @@ +import QuestionCard from '@/domain/admin/blog/magnet/form/QuestionCard'; +import { FormQuestion } from '@/domain/admin/blog/magnet/types'; +import { Button } from '@mui/material'; +import { Plus } from 'lucide-react'; + +interface FormBuilderSectionProps { + questions: FormQuestion[]; + onUpdateQuestion: ( + questionId: string, + patch: Partial, + ) => void; + onRemoveQuestion: (questionId: string) => void; + onAddQuestion: () => void; +} + +const FormBuilderSection = ({ + questions, + onUpdateQuestion, + onRemoveQuestion, + onAddQuestion, +}: FormBuilderSectionProps) => { + return ( +
+
+ {questions.map((question, index) => ( + + onUpdateQuestion(question.questionId, patch) + } + onRemove={() => onRemoveQuestion(question.questionId)} + /> + ))} +
+ +
+ +
+
+ ); +}; + +export default FormBuilderSection; diff --git a/src/domain/admin/blog/magnet/form/QuestionCard.tsx b/src/domain/admin/blog/magnet/form/QuestionCard.tsx new file mode 100644 index 000000000..1a43c95d0 --- /dev/null +++ b/src/domain/admin/blog/magnet/form/QuestionCard.tsx @@ -0,0 +1,165 @@ +import QuestionItemList from '@/domain/admin/blog/magnet/form/QuestionItemList'; +import { + FormQuestion, + FormQuestionItem, + FormQuestionType, + FormResponseRequired, + FormSelectionMethod, +} from '@/domain/admin/blog/magnet/types'; +import { + FormControlLabel, + IconButton, + Radio, + RadioGroup, + TextField, +} from '@mui/material'; +import { Trash } from 'lucide-react'; + +interface QuestionCardProps { + questionNumber: number; + question: FormQuestion; + onUpdate: (patch: Partial) => void; + onRemove: () => void; +} + +const QuestionCard = ({ + questionNumber, + question, + onUpdate, + onRemove, +}: QuestionCardProps) => { + const handleQuestionTypeChange = (value: string) => { + onUpdate({ questionType: value as FormQuestionType }); + }; + + const handleRequiredChange = (value: string) => { + onUpdate({ isRequired: value as FormResponseRequired }); + }; + + const handleSelectionMethodChange = (value: string) => { + onUpdate({ selectionMethod: value as FormSelectionMethod }); + }; + + const handleItemsChange = (items: FormQuestionItem[]) => { + onUpdate({ items }); + }; + + return ( +
+
+ + 질문 {questionNumber} + + + + +
+ +
+ {/* 질문 유형 */} +
+ + handleQuestionTypeChange(e.target.value)} + > + } + label="주관식" + /> + } + label="객관식" + /> + +
+ + {/* 응답 설정 */} +
+ + handleRequiredChange(e.target.value)} + > + } + label="필수" + /> + } + label="선택" + /> + +
+ + {/* 질문 */} + onUpdate({ question: e.target.value })} + /> + + {/* 설명 */} + onUpdate({ description: e.target.value })} + /> + + {/* 객관식 전용 영역 */} + {question.questionType === 'OBJECTIVE' && ( + <> +
+ + + handleSelectionMethodChange(e.target.value) + } + > + } + label="단일선택" + /> + } + label="다중선택" + /> + +
+ + + + )} +
+
+ ); +}; + +export default QuestionCard; diff --git a/src/domain/admin/blog/magnet/form/QuestionItemList.tsx b/src/domain/admin/blog/magnet/form/QuestionItemList.tsx new file mode 100644 index 000000000..332ded45f --- /dev/null +++ b/src/domain/admin/blog/magnet/form/QuestionItemList.tsx @@ -0,0 +1,108 @@ +import { + createEmptyItem, + createOtherItem, +} from '@/domain/admin/blog/magnet/hooks/useMagnetFormBuilder'; +import { FormQuestionItem } from '@/domain/admin/blog/magnet/types'; +import { Button, Chip, IconButton, TextField } from '@mui/material'; +import { Plus, X } from 'lucide-react'; + +interface QuestionItemListProps { + items: FormQuestionItem[]; + onUpdateItems: (items: FormQuestionItem[]) => void; +} + +const QuestionItemList = ({ + items, + onUpdateItems, +}: QuestionItemListProps) => { + const hasOtherItem = items.some((item) => item.isOther); + + const handleAddItem = () => { + onUpdateItems([...items, createEmptyItem()]); + }; + + const handleAddOtherItem = () => { + if (hasOtherItem) return; + onUpdateItems([...items, createOtherItem()]); + }; + + const handleRemoveItem = (itemId: string) => { + onUpdateItems(items.filter((item) => item.itemId !== itemId)); + }; + + const handleUpdateItemValue = (itemId: string, value: string) => { + onUpdateItems( + items.map((item) => + item.itemId === itemId ? { ...item, value } : item, + ), + ); + }; + + return ( +
+ +
+ {items.map((item, index) => ( +
+ + {index + 1}. + + {item.isOther ? ( +
+ + 기타(직접입력) + + +
+ ) : ( + + handleUpdateItemValue(item.itemId, e.target.value) + } + /> + )} + handleRemoveItem(item.itemId)} + > + + +
+ ))} +
+
+ + +
+
+ ); +}; + +export default QuestionItemList; diff --git a/src/domain/admin/blog/magnet/hooks/useCommonFormBuilder.ts b/src/domain/admin/blog/magnet/hooks/useCommonFormBuilder.ts new file mode 100644 index 000000000..24e54456e --- /dev/null +++ b/src/domain/admin/blog/magnet/hooks/useCommonFormBuilder.ts @@ -0,0 +1,90 @@ +import { useAdminSnackbar } from '@/hooks/useAdminSnackbar'; +import { generateUUID } from '@/utils/random'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { saveCommonForm } from '../mock'; +import { CommonFormData, FormQuestion } from '../types'; + +function createEmptyQuestion(): FormQuestion { + return { + questionId: generateUUID(), + questionType: 'SUBJECTIVE', + isRequired: 'REQUIRED', + question: '', + description: '', + selectionMethod: 'SINGLE', + items: [], + }; +} + +interface UseCommonFormBuilderParams { + initialData: CommonFormData; +} + +export const useCommonFormBuilder = ({ + initialData, +}: UseCommonFormBuilderParams) => { + const router = useRouter(); + const { snackbar: setSnackbar } = useAdminSnackbar(); + + const [questions, setQuestions] = useState( + initialData.questions, + ); + + const addQuestion = () => { + setQuestions((prev) => [...prev, createEmptyQuestion()]); + }; + + const removeQuestion = (questionId: string) => { + setQuestions((prev) => + prev.filter((q) => q.questionId !== questionId), + ); + }; + + const updateQuestion = ( + questionId: string, + patch: Partial, + ) => { + setQuestions((prev) => + prev.map((q) => + q.questionId === questionId ? { ...q, ...patch } : q, + ), + ); + }; + + const saveForm = async () => { + const emptyQuestion = questions.find( + (q) => q.question.trim() === '', + ); + if (emptyQuestion) { + setSnackbar('질문 텍스트를 입력해주세요.'); + return; + } + + const invalidObjective = questions.find((q) => { + const hasNoSelectableItems = + q.items.filter((i) => !i.isOther).length === 0; + return q.questionType === 'OBJECTIVE' && hasNoSelectableItems; + }); + if (invalidObjective) { + setSnackbar('객관식 질문에는 최소 1개의 항목이 필요합니다.'); + return; + } + + await saveCommonForm({ questions }); + setSnackbar('공통 신청폼이 저장되었습니다.'); + }; + + const navigateToList = () => { + router.push('/admin/blog/magnet/list'); + }; + + return { + questions, + addQuestion, + removeQuestion, + updateQuestion, + saveForm, + navigateToList, + }; +}; diff --git a/src/domain/admin/blog/magnet/hooks/useMagnetFormBuilder.ts b/src/domain/admin/blog/magnet/hooks/useMagnetFormBuilder.ts new file mode 100644 index 000000000..ff1026fdb --- /dev/null +++ b/src/domain/admin/blog/magnet/hooks/useMagnetFormBuilder.ts @@ -0,0 +1,124 @@ +import { useAdminSnackbar } from '@/hooks/useAdminSnackbar'; +import { generateUUID } from '@/utils/random'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { saveMagnetForm } from '../mock'; +import { + FormQuestion, + FormQuestionItem, + MagnetFormData, +} from '../types'; + +function createEmptyQuestion(): FormQuestion { + return { + questionId: generateUUID(), + questionType: 'SUBJECTIVE', + isRequired: 'REQUIRED', + question: '', + description: '', + selectionMethod: 'SINGLE', + items: [], + }; +} + +export function createEmptyItem(): FormQuestionItem { + return { itemId: generateUUID(), value: '', isOther: false }; +} + +export function createOtherItem(): FormQuestionItem { + return { + itemId: generateUUID(), + value: '기타(직접입력)', + isOther: true, + }; +} + +interface UseMagnetFormBuilderParams { + magnetId: string; + initialData: MagnetFormData; +} + +export const useMagnetFormBuilder = ({ + magnetId, + initialData, +}: UseMagnetFormBuilderParams) => { + const router = useRouter(); + const { snackbar: setSnackbar } = useAdminSnackbar(); + + const [questions, setQuestions] = useState( + initialData.questions, + ); + + const addQuestion = () => { + setQuestions((prev) => [...prev, createEmptyQuestion()]); + }; + + const removeQuestion = (questionId: string) => { + setQuestions((prev) => + prev.filter((q) => q.questionId !== questionId), + ); + }; + + const updateQuestion = ( + questionId: string, + patch: Partial, + ) => { + setQuestions((prev) => + prev.map((q) => + q.questionId === questionId ? { ...q, ...patch } : q, + ), + ); + }; + + const cloneFromMagnet = (clonedQuestions: FormQuestion[]) => { + const reIdQuestions = clonedQuestions.map((q) => ({ + ...q, + questionId: generateUUID(), + items: q.items.map((item) => ({ + ...item, + itemId: generateUUID(), + })), + })); + setQuestions(reIdQuestions); + }; + + const saveForm = async () => { + const emptyQuestion = questions.find( + (q) => q.question.trim() === '', + ); + if (emptyQuestion) { + setSnackbar('질문 텍스트를 입력해주세요.'); + return; + } + + const invalidObjective = questions.find((q) => { + const hasNoSelectableItems = + q.items.filter((i) => !i.isOther).length === 0; + return q.questionType === 'OBJECTIVE' && hasNoSelectableItems; + }); + if (invalidObjective) { + setSnackbar('객관식 질문에는 최소 1개의 항목이 필요합니다.'); + return; + } + + await saveMagnetForm({ + magnetId: Number(magnetId), + questions, + }); + setSnackbar('신청폼이 저장되었습니다.'); + }; + + const navigateToList = () => { + router.push('/admin/blog/magnet/list'); + }; + + return { + questions, + addQuestion, + removeQuestion, + updateQuestion, + cloneFromMagnet, + saveForm, + navigateToList, + }; +}; diff --git a/src/domain/admin/blog/magnet/mock.ts b/src/domain/admin/blog/magnet/mock.ts index 016215604..e8a2c4651 100644 --- a/src/domain/admin/blog/magnet/mock.ts +++ b/src/domain/admin/blog/magnet/mock.ts @@ -1,11 +1,16 @@ // TODO: API 준비 후 src/api/magnet/ 폴더로 이동하고 실제 API 호출로 교체 import { IPageInfo } from '@/types/interface'; import { + CommonFormData, + CommonFormReqBody, MANAGEABLE_MAGNET_TYPES, + MagnetFormData, + MagnetFormReqBody, MagnetListItem, MagnetPostDetail, MagnetPostReqBody, MagnetTypeKey, + MagnetWithFormSummary, } from './types'; const MOCK_MAGNETS: MagnetListItem[] = [ @@ -210,3 +215,123 @@ export function fetchManageableMagnets(): MagnetListItem[] { MANAGEABLE_MAGNET_TYPES.includes(m.type), ); } + +// --- 마그넷 신청폼 관리 --- + +const MOCK_MAGNET_FORMS: Record = { + 5: { + magnetId: 5, + questions: [ + { + questionId: 'q1', + questionType: 'SUBJECTIVE', + isRequired: 'REQUIRED', + question: '이름을 입력해주세요', + description: '', + selectionMethod: 'SINGLE', + items: [], + }, + { + questionId: 'q2', + questionType: 'OBJECTIVE', + isRequired: 'REQUIRED', + question: '관심 직무를 선택해주세요', + description: '복수 선택이 가능합니다', + selectionMethod: 'MULTIPLE', + items: [ + { itemId: 'i1', value: '마케팅', isOther: false }, + { itemId: 'i2', value: '기획', isOther: false }, + { itemId: 'i3', value: '개발', isOther: false }, + { itemId: 'i4', value: '디자인', isOther: false }, + { itemId: 'i5', value: '기타(직접입력)', isOther: true }, + ], + }, + { + questionId: 'q3', + questionType: 'SUBJECTIVE', + isRequired: 'OPTIONAL', + question: '자료집을 알게 된 경로를 알려주세요', + description: '선택사항입니다', + selectionMethod: 'SINGLE', + items: [], + }, + ], + }, +}; + +function buildDefaultForm(magnetId: number): MagnetFormData { + return { magnetId, questions: [] }; +} + +// TODO: API 준비 후 server-side fetch로 교체 +export async function fetchMagnetForm( + magnetId: number, +): Promise { + return MOCK_MAGNET_FORMS[magnetId] ?? buildDefaultForm(magnetId); +} + +// TODO: API 준비 후 useSaveMagnetFormMutation React Query 훅으로 교체 +export async function saveMagnetForm( + body: MagnetFormReqBody, +): Promise { + MOCK_MAGNET_FORMS[body.magnetId] = { + magnetId: body.magnetId, + questions: body.questions, + }; +} + +// TODO: API 준비 후 React Query 훅으로 교체 +export function fetchMagnetsWithForm(): MagnetWithFormSummary[] { + return Object.entries(MOCK_MAGNET_FORMS).map(([id, data]) => { + const magnet = MOCK_MAGNETS.find((m) => m.id === Number(id)); + return { + id: Number(id), + title: magnet?.title ?? `마그넷 ${id}`, + type: magnet?.type ?? 'RESOURCE', + questionCount: data.questions.length, + }; + }); +} + +// --- 공통 신청폼 관리 --- + +let MOCK_COMMON_FORM: CommonFormData = { + questions: [ + { + questionId: 'common-q1', + questionType: 'OBJECTIVE', + isRequired: 'REQUIRED', + question: '관심 직무를 선택해주세요', + description: '복수 선택이 가능합니다', + selectionMethod: 'MULTIPLE', + items: [ + { itemId: 'common-i1', value: '마케팅', isOther: false }, + { itemId: 'common-i2', value: '기획', isOther: false }, + { itemId: 'common-i3', value: '개발', isOther: false }, + { itemId: 'common-i4', value: '디자인', isOther: false }, + { itemId: 'common-i5', value: '기타(직접입력)', isOther: true }, + ], + }, + { + questionId: 'common-q2', + questionType: 'SUBJECTIVE', + isRequired: 'OPTIONAL', + question: '이름을 입력해주세요', + description: '', + selectionMethod: 'SINGLE', + items: [], + }, + ], +}; + +// TODO: API 준비 후 server-side fetch로 교체 +export async function fetchCommonForm(): Promise { + return MOCK_COMMON_FORM; +} + +// TODO: API 준비 후 useSaveCommonFormMutation React Query 훅으로 교체 +export async function saveCommonForm( + body: CommonFormReqBody, +): Promise { + MOCK_COMMON_FORM = { questions: body.questions }; +} diff --git a/src/domain/admin/blog/magnet/types.ts b/src/domain/admin/blog/magnet/types.ts index 4f67a3787..74e9e90f9 100644 --- a/src/domain/admin/blog/magnet/types.ts +++ b/src/domain/admin/blog/magnet/types.ts @@ -88,3 +88,64 @@ export interface MagnetPostReqBody { content: string; isVisible: boolean; } + +// --- 마그넷 신청폼 관리 --- + +/** 질문 유형 */ +export type FormQuestionType = 'SUBJECTIVE' | 'OBJECTIVE'; + +/** 응답 설정 */ +export type FormResponseRequired = 'REQUIRED' | 'OPTIONAL'; + +/** 객관식 선택 방식 */ +export type FormSelectionMethod = 'SINGLE' | 'MULTIPLE'; + +/** 객관식 항목 */ +export interface FormQuestionItem { + itemId: string; + value: string; + isOther: boolean; +} + +/** 질문 */ +export interface FormQuestion { + questionId: string; + questionType: FormQuestionType; + isRequired: FormResponseRequired; + question: string; + description: string; + selectionMethod: FormSelectionMethod; + items: FormQuestionItem[]; +} + +/** 마그넷 신청폼 전체 데이터 */ +export interface MagnetFormData { + magnetId: number; + questions: FormQuestion[]; +} + +/** 마그넷 신청폼 저장 요청 */ +export interface MagnetFormReqBody { + magnetId: number; + questions: FormQuestion[]; +} + +/** 복제 가능한 마그넷 요약 (폼이 있는 마그넷 목록) */ +export interface MagnetWithFormSummary { + id: number; + title: string; + type: MagnetTypeKey; + questionCount: number; +} + +// --- 공통 신청폼 관리 --- + +/** 공통 신청폼 전체 데이터 (마그넷 독립) */ +export interface CommonFormData { + questions: FormQuestion[]; +} + +/** 공통 신청폼 저장 요청 */ +export interface CommonFormReqBody { + questions: FormQuestion[]; +}