diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eabf3b0aa..30c91e9ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,8 @@ import AccountEditTab from '@/pages/AdminPage/tabs/AccountEditTab/AccountEditTab import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; import PhotoEditTab from '@/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab'; +import ApplicationForm from '@/pages/AdminPage/application/ApplicationForm'; +import CreateForm from '@/pages/AdminPage/application/CreateForm'; const queryClient = new QueryClient(); @@ -69,6 +71,9 @@ const App = () => { } /> + } /> + {/*TODO: CreateForm은 관리자 기능이므로 추후 /admin/* 경로 안으로 이동 필요*/} + } /> } /> diff --git a/frontend/src/assets/images/icons/drop_button_icon.svg b/frontend/src/assets/images/icons/drop_button_icon.svg new file mode 100644 index 000000000..24399c538 --- /dev/null +++ b/frontend/src/assets/images/icons/drop_button_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/constants/APPLICATION_FORM.ts b/frontend/src/constants/APPLICATION_FORM.ts new file mode 100644 index 000000000..4444569c5 --- /dev/null +++ b/frontend/src/constants/APPLICATION_FORM.ts @@ -0,0 +1,12 @@ +const APPLICATION_FORM = { + SHORT_TEXT: { + placeholder: '답변입력란(최대 20자)', + }, + LONG_TEXT: { + placeholder: '답변입력란(최대 500자)', + }, + CHOICE: { + placeholder: '항목(최대 20자)', + }, +} as const; +export default APPLICATION_FORM; diff --git a/frontend/src/pages/AdminPage/application/ApplicationForm.tsx b/frontend/src/pages/AdminPage/application/ApplicationForm.tsx new file mode 100644 index 000000000..51b2bb839 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/ApplicationForm.tsx @@ -0,0 +1,71 @@ +// 지원하기 : 지원서 입력 컴포넌트 +// 특정 지원서를 사용자들이 입력할 수 있는 지원서 작성 부분 +// Todo 추후 특정 지원서를 받아서 answer 모드로 렌더링 +import { useState } from 'react'; +import ShortText from '@/pages/AdminPage/application/fields/ShortText'; +import Choice from '@/pages/AdminPage/application/fields/Choice'; + +interface QuestionData { + title: string; + description: string; + items?: { value: string }[]; +} + +interface AnswerData { + [id: number]: string; +} + +const ApplicationForm = () => { + const [questions] = useState>({ + 1: { + title: '이름을 입력해주세요', + description: '본명을 입력해 주세요', + }, + 2: { + title: '자기소개를 해주세요', + description: '300자 이내로 입력해주세요', + }, + 3: { + title: '지원 분야를 선택해주세요', + description: '중복 선택은 불가능합니다', + items: [ + { value: '프론트엔드' }, + { value: '백엔드' }, + { value: '디자인' }, + ], + }, + 4: { + title: '희망하는 활동 시간을 선택해주세요', + description: '가장 편한 시간을 골라주세요', + items: [], + }, + 5: { + title: '이전에 프로젝트 경험이 있나요?', + description: '간단하게 작성해주세요', + items: [{ value: 'React' }], + }, + 6: { + title: '사용 가능한 기술 스택을 선택해주세요', + description: '복수 선택 가능', + items: [ + { value: 'React' }, + { value: 'Node.js' }, + { value: 'Python' }, + { value: 'Figma' }, + ], + }, + }); + + const [answers, setAnswers] = useState({}); + + const handleAnswerChange = (id: number) => (value: string) => { + setAnswers((prev) => ({ + ...prev, + [id]: value, + })); + }; + + return <>; +}; + +export default ApplicationForm; diff --git a/frontend/src/pages/AdminPage/application/CreateForm.tsx b/frontend/src/pages/AdminPage/application/CreateForm.tsx new file mode 100644 index 000000000..06d880ada --- /dev/null +++ b/frontend/src/pages/AdminPage/application/CreateForm.tsx @@ -0,0 +1,142 @@ +// 지원서 제작하기 : 지원서 제작 컴포넌트 +// 지원서 수정과 제작을 맡을 컴포넌트 +// Todo: 질문 삭제 및 질문 추가 기능 구현 +import { useState } from 'react'; +import QuestionBuilder from '@/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder'; + +type QuestionType = + | 'SHORT_TEXT' + | 'LONG_TEXT' + | 'CHOICE' + | 'MULTI_CHOICE' + | 'EMAIL' + | 'PHONE_NUMBER' + | 'NAME'; + +type Question = { + title: string; + description: string; + type: QuestionType; + options: { + required: boolean; + }; + items?: { value: string }[]; +}; + +const CreateForm = () => { + const [questions, setQuestions] = useState>({ + 1: { + title: '이름을 입력해주세요', + description: '본명을 입력해 주세요', + options: { + required: true, + }, + type: 'SHORT_TEXT', + }, + 2: { + title: '지원 분야를 선택해주세요', + description: '중복 선택 가능합니다', + options: { + required: false, + }, + items: [ + { value: '프론트엔드' }, + { value: '백엔드' }, + { value: '디자인' }, + ], + type: 'MULTI_CHOICE', + }, + }); + + const handleTitleChange = (id: number) => (value: string) => { + setQuestions((prev) => ({ + ...prev, + [id]: { + ...prev[id], + title: value, + }, + })); + }; + + const handleDescriptionChange = (id: number) => (value: string) => { + setQuestions((prev) => ({ + ...prev, + [id]: { + ...prev[id], + description: value, + }, + })); + }; + + const handleItemsChange = (id: number) => (newItems: { value: string }[]) => { + setQuestions((prev) => ({ + ...prev, + [id]: { + ...prev[id], + items: newItems, + }, + })); + }; + + const handleTypeChange = (id: number) => (newType: QuestionType) => { + setQuestions((prev) => { + const prevQuestion = prev[id]; + const isChoiceType = newType === 'CHOICE' || newType === 'MULTI_CHOICE'; + + const updatedQuestion: Question = { + ...prevQuestion, + type: newType, + }; + + if (isChoiceType) { + updatedQuestion.items = + !prevQuestion.items || prevQuestion.items.length < 2 + ? [{ value: '' }, { value: '' }] + : prevQuestion.items; + } else { + delete updatedQuestion.items; + } + + return { + ...prev, + [id]: updatedQuestion, + }; + }); + }; + + const handleRequiredChange = (id: number) => (value: boolean) => { + setQuestions((prev) => ({ + ...prev, + [id]: { + ...prev[id], + options: { + ...prev[id].options, + required: value, + }, + }, + })); + }; + + return ( + <> + {Object.entries(questions).map(([id, question]) => ( + + ))} + + ); +}; + +export default CreateForm; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.styles.ts b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.styles.ts new file mode 100644 index 000000000..0f6227c7c --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.styles.ts @@ -0,0 +1,101 @@ +import styled from 'styled-components'; + +export const QuestionMenu = styled.div` + display: flex; + width: 140px; + flex-direction: column; + gap: 4px; +`; + +export const RequiredToggleButton = styled.div` + display: flex; + border: none; + padding: 12px 16px; + justify-content: space-between; + align-items: center; + border-radius: 0.375rem; + background: #f5f5f5; + cursor: pointer; + margin: 0; + color: #787878; + font-size: 0.875rem; + font-weight: 600; +`; + +export const RequiredToggleCircle = styled.span<{ active?: boolean }>` + position: relative; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid ${(props) => (props.active ? '#ff5000' : '#ccc')}; + background-color: ${(props) => (props.active ? '#ff5000' : 'white')}; + + ${({ active }) => + active && + ` + background-color: #fff; + &::after { + content: ''; + width: 10px; + height: 10px; + background-color: #ff5000; + border-radius: 50%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + `} +`; + +export const DropDownWrapper = styled.div` + position: relative; + width: 100%; +`; + +export const Dropdown = styled.select` + display: flex; + width: 100%; + border: none; + padding: 12px 16px; + border-radius: 0.375rem; + background: #f5f5f5; + color: #787878; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + appearance: none; +`; + +export const DropdownIcon = styled.img` + position: absolute; + top: 50%; + right: 19px; + transform: translateY(-50%); + pointer-events: none; +`; + +export const SelectionToggleWrapper = styled.div` + display: flex; + background-color: #f7f7f7; + border-radius: 0.375rem; + padding: 2px; +`; + +export const SelectionToggleButton = styled.button<{ active: boolean }>` + border: none; + background-color: ${(props) => (props.active ? '#ddd' : 'transparent')}; + color: #787878; + font-size: 0.875rem; + border-radius: 0.375rem; + padding: 10px; + font-weight: 600; + cursor: pointer; + letter-spacing: -0.42px; + white-space: nowrap; +`; + +export const QuestionWrapper = styled.div` + display: flex; + gap: 36px; +`; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.tsx b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.tsx new file mode 100644 index 000000000..a06cf7d4e --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionBuilder/QuestionBuilder.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react'; +import ShortText from '@/pages/AdminPage/application/fields/ShortText'; +import dropdown_icon from '@/assets/images/icons/drop_button_icon.svg'; +import Choice from '@/pages/AdminPage/application/fields/Choice'; +import * as Styled from './QuestionBuilder.styles'; + +type QuestionType = + | 'CHOICE' + | 'MULTI_CHOICE' + | 'SHORT_TEXT' + | 'LONG_TEXT' + | 'PHONE_NUMBER' + | 'EMAIL' + | 'NAME'; + +interface QuestionBuilderProps { + id: number; + title: string; + description: string; + items?: { value: string }[]; + type: QuestionType; + required: boolean; + onTitleChange: (value: string) => void; + onDescriptionChange: (value: string) => void; + onItemsChange?: (newItems: { value: string }[]) => void; + onTypeChange?: (type: QuestionType) => void; + onRequiredChange?: (required: boolean) => void; +} + +const QuestionBuilder = ({ + id, + title, + description, + required, + items, + type, + onTitleChange, + onItemsChange, + onDescriptionChange, + onTypeChange, + onRequiredChange, +}: QuestionBuilderProps) => { + const [selectionType, setSelectionType] = useState<'single' | 'multi'>( + type === 'MULTI_CHOICE' ? 'multi' : 'single', + ); + + useEffect(() => { + if (type === 'MULTI_CHOICE') { + setSelectionType('multi'); + } else if (type === 'CHOICE') { + setSelectionType('single'); + } + }, [type]); + + const renderFieldByQuestionType = () => { + switch (type) { + case 'SHORT_TEXT': + case 'NAME': + case 'EMAIL': + case 'PHONE_NUMBER': + return ( + + ); + // Todo case 'LONG_TEXT': 와 같은 다른 케이스도 여기서 렌더링 가능 + case 'CHOICE': + case 'MULTI_CHOICE': + return ( + + ); + default: + return null; + } + }; + + const renderSelectionToggle = () => { + if (type !== 'CHOICE' && type !== 'MULTI_CHOICE') return null; + + return ( + + { + setSelectionType('single'); + onTypeChange?.('CHOICE'); + }} + > + 단일선택 + + { + setSelectionType('multi'); + onTypeChange?.('MULTI_CHOICE'); + }} + > + 다중선택 + + + ); + }; + + return ( + + + onRequiredChange?.(!required)} + > + 답변 필수 + + + + + { + const selectedType = e.target.value as QuestionType; + onTypeChange?.(selectedType); + }} + > + + + + + {renderSelectionToggle()} + + {renderFieldByQuestionType()} + + ); +}; + +export default QuestionBuilder; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionDescription/QuestionDescription.tsx b/frontend/src/pages/AdminPage/application/components/QuestionDescription/QuestionDescription.tsx new file mode 100644 index 000000000..4ca603c16 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionDescription/QuestionDescription.tsx @@ -0,0 +1,37 @@ +import styled from 'styled-components'; + +interface QuestionDescriptionProps { + description: string; + mode: 'builder' | 'answer'; + onChange?: (value: string) => void; +} + +const QuestionDescriptionText = styled.input` + border: none; + outline: none; + color: #c5c5c5; + font-size: 0.8125rem; + font-weight: 400; + line-height: normal; + letter-spacing: -0.26px; +`; + +const QuestionDescription = ({ + description, + mode, + onChange, +}: QuestionDescriptionProps) => { + return ( + <> + onChange?.(e.target.value)} + /> + + ); +}; + +export default QuestionDescription; diff --git a/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.tsx b/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.tsx new file mode 100644 index 000000000..7de360ba6 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/components/QuestionTitle/QuestionTitle.tsx @@ -0,0 +1,64 @@ +import styled from 'styled-components'; + +const QuestionTitleContainer = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const QuestionTitleId = styled.p` + color: #ff5414; + font-size: 1.25rem; + font-weight: 700; + line-height: normal; +`; + +const QuestionTitleText = styled.input` + border: none; + outline: none; + color: #111; + font-size: 1.25rem; + font-weight: 700; + line-height: normal; +`; + +const QuestionRequired = styled.div` + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #ff5414; + margin-left: 14px; +`; + +interface QuestionTitleProps { + id: number; + title: string; + required?: boolean; + mode: 'builder' | 'answer'; + onChange?: (value: string) => void; +} + +const QuestionTitle = ({ + id, + title, + required, + mode, + onChange, +}: QuestionTitleProps) => { + return ( + + {id && {id}.} + onChange?.(e.target.value)} + /> + {mode === 'answer' && required && } + + ); +}; + +export default QuestionTitle; diff --git a/frontend/src/pages/AdminPage/application/fields/Choice.tsx b/frontend/src/pages/AdminPage/application/fields/Choice.tsx new file mode 100644 index 000000000..821f388bb --- /dev/null +++ b/frontend/src/pages/AdminPage/application/fields/Choice.tsx @@ -0,0 +1,121 @@ +import styled from 'styled-components'; +import QuestionTitle from '@/pages/AdminPage/application/components/QuestionTitle/QuestionTitle'; +import QuestionDescription from '@/pages/AdminPage/application/components/QuestionDescription/QuestionDescription'; +import InputField from '@/components/common/InputField/InputField'; +import APPLICATION_FORM from '@/constants/APPLICATION_FORM'; + +interface ChoiceProps { + id: number; + title: string; + description: string; + required: boolean; + mode: 'builder' | 'answer'; + onTitleChange?: (value: string) => void; + onDescriptionChange?: (value: string) => void; + items?: { value: string }[]; + answer?: string; + isMulti?: boolean; + onItemsChange?: (newItems: { value: string }[]) => void; +} + +const AddItemButton = styled.button` + padding: 8px 12px; + border-radius: 6px; + border: 1px solid #ccc; + font-size: 0.875rem; + font-weight: 500; + background: white; + color: #555; + margin-top: 8px; + cursor: pointer; +`; + +const ItemWrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +`; + +const DeleteButton = styled.button` + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 4px; + background-color: #ffecec; + color: #e33; + border: 1px solid #f99; + cursor: pointer; +`; + +const MIN_ITEMS = 2; +const MAX_ITEMS = 6; + +//todo isMulti나 질문 타입을 props로 받아서 다중 선택이 가능하도록 기능 추가 필요 +const Choice = ({ + id, + title, + description, + required, + mode, + onTitleChange, + onDescriptionChange, + items = [], + isMulti, + onItemsChange, +}: ChoiceProps) => { + const handleItemChange = (index: number, newValue: string) => { + const updated = items.map((item, i) => + i === index ? { ...item, value: newValue } : item, + ); + onItemsChange?.(updated); + }; + + const handleAddItem = () => { + if (items.length >= MAX_ITEMS) return; + onItemsChange?.([...items, { value: '' }]); + }; + + const handleDeleteItem = (index: number) => { + if (items.length <= MIN_ITEMS) return; + const updated = items.filter((_, i) => i !== index); + onItemsChange?.(updated); + }; + + return ( +
+ + + {items.map((item, index) => ( + + handleItemChange(index, e.target.value)} + placeholder={APPLICATION_FORM.CHOICE.placeholder} + disabled={mode === 'answer'} + /> + {mode === 'builder' && items.length > MIN_ITEMS && ( + handleDeleteItem(index)}> + 삭제 + + )} + + ))} + + {mode === 'builder' && items.length < MAX_ITEMS && ( + + 추가항목 + )} +
+ ); +}; + +export default Choice; diff --git a/frontend/src/pages/AdminPage/application/fields/ShortText.tsx b/frontend/src/pages/AdminPage/application/fields/ShortText.tsx new file mode 100644 index 000000000..1c6346aa7 --- /dev/null +++ b/frontend/src/pages/AdminPage/application/fields/ShortText.tsx @@ -0,0 +1,53 @@ +import QuestionTitle from '@/pages/AdminPage/application/components/QuestionTitle/QuestionTitle'; +import QuestionDescription from '@/pages/AdminPage/application/components/QuestionDescription/QuestionDescription'; +import InputField from '@/components/common/InputField/InputField'; +import APPLICATION_FORM from '@/constants/APPLICATION_FORM'; + +interface ShortTextProps { + id: number; + title: string; + description: string; + required: boolean; + mode: 'builder' | 'answer'; + answer?: string; + onChange?: (value: string) => void; + onTitleChange?: (value: string) => void; + onDescriptionChange?: (value: string) => void; +} + +const ShortText = ({ + id, + title, + description, + required, + answer, + mode, + onChange, + onTitleChange, + onDescriptionChange, +}: ShortTextProps) => { + return ( +
+ + + onChange?.(e.target.value)} + placeholder={APPLICATION_FORM.SHORT_TEXT.placeholder} + disabled={mode === 'builder'} + /> +
+ ); +}; + +export default ShortText;