diff --git a/src/entities/projects/ui/project-insert/ProjectExpectedPeriodCard.tsx b/src/entities/projects/ui/project-insert/ProjectExpectedPeriodCard.tsx new file mode 100644 index 0000000..85b2957 --- /dev/null +++ b/src/entities/projects/ui/project-insert/ProjectExpectedPeriodCard.tsx @@ -0,0 +1,77 @@ +import { FormControl, Select, MenuItem } from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import type { CSSProperties, JSX } from "react"; + +import { ExpectedPeriod } from "@shared/types/schedule"; +import SimpleFormCard from "@shared/ui/project-insert/SimpleFormCard"; + +interface ProjectScheduleCardProps { + value: ExpectedPeriod | ""; + onChange: (event: SelectChangeEvent) => void; + large?: boolean; + style?: CSSProperties; +} + +export default function ProjectScheduleCard({ + value, + onChange, + large = false, + style, +}: ProjectScheduleCardProps): JSX.Element { + const theme = useTheme(); + + return ( + + + + value={value || ("" as ExpectedPeriod)} + onChange={onChange} + size={large ? "medium" : "small"} + displayEmpty + sx={{ + fontSize: large + ? theme.typography.h5.fontSize + : theme.typography.body1.fontSize, + fontFamily: theme.typography.fontFamily, + padding: large ? theme.spacing(2.2) : theme.spacing(1.7), + height: 40, + "& .MuiSelect-select": { + height: "40px", + display: "flex", + alignItems: "center", + padding: 0, + }, + }} + > + + + {ExpectedPeriod.oneMonth} + + + {ExpectedPeriod.twoMonths} + + + {ExpectedPeriod.threeMonths} + + + {ExpectedPeriod.fourMonths} + + + {ExpectedPeriod.sixMonths} + + + {ExpectedPeriod.moreThanSixMonths} + + + + + ); +} diff --git a/src/entities/projects/ui/project-insert/ProjectPositionsCard.tsx b/src/entities/projects/ui/project-insert/ProjectPositionsCard.tsx new file mode 100644 index 0000000..047909b --- /dev/null +++ b/src/entities/projects/ui/project-insert/ProjectPositionsCard.tsx @@ -0,0 +1,216 @@ +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { + Box, + Button, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + useMediaQuery, + useTheme, +} from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material"; +import type { CSSProperties, JSX } from "react"; + +import type { Positions } from "@shared/types/project"; +import type { UserRole } from "@shared/types/user"; +import SimpleFormCard from "@shared/ui/project-insert/SimpleFormCard"; + +interface ProjectPositionsCardProps { + value: Positions[]; + onChange: (value: Positions[]) => void; + large?: boolean; + style?: CSSProperties; +} + +const USER_ROLES = [ + { value: "frontend", label: "프론트엔드 개발자" }, + { value: "backend", label: "백엔드 개발자" }, + { value: "fullstack", label: "풀스택 개발자" }, + { value: "designer", label: "디자이너" }, + { value: "pm", label: "프로덕트 매니저" }, +]; + +const EXPERIENCE_OPTIONS = [ + { value: "junior", label: "주니어 (3년 이하)" }, + { value: "mid", label: "미들 (3년 이상 10년 이하)" }, + { value: "senior", label: "시니어 (10년 이상)" }, +]; + +const ProjectPositionsCard = ({ + value, + onChange, + large, + style, +}: ProjectPositionsCardProps): JSX.Element => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + + // 초기 포지션이 없을 때 하나 추가 + const positions = value.length > 0 ? value : []; + + // 포지션 추가 + const addPosition = (): void => { + const newPosition: Positions = { + position: "" as UserRole, + count: 1, + experience: "", + applicants: [], + }; + onChange([...value, newPosition]); + }; + + // 포지션 삭제 + const removePosition = (index: number): void => { + const newPositions = value.filter((_, i) => i !== index); + onChange(newPositions); + }; + + // 포지션 수정 - 타입 안전성 개선 + const updatePosition = ( + index: number, + field: keyof Positions, + newValue: string | number + ): void => { + const newPositions = [...value]; + if (index < newPositions.length) { + newPositions[index] = { ...newPositions[index], [field]: newValue }; + onChange(newPositions); + } + }; + + // 최소 하나의 포지션이 없으면 추가 + if (positions.length === 0) { + setTimeout(() => addPosition(), 0); + return
Loading...
; + } + + return ( + + + {/* 포지션 목록 */} + {positions.map((position, index) => ( + + {/* 포지션 선택 */} + + 포지션 + + + + {/* 인원 수 */} + + 인원 + + + + {/* 경력 요구사항 */} + + 경력 + + + + {/* 삭제 버튼 */} + removePosition(index)} + color="error" + size="small" + disabled={positions.length <= 1} + sx={{ + "&:hover": { + backgroundColor: "transparent", + }, + }} + > + + + + ))} + + {/* 포지션 추가 버튼 */} + + + + + + ); +}; + +export default ProjectPositionsCard; diff --git a/src/entities/projects/ui/project-insert/ProjectTeamSizeCard.tsx b/src/entities/projects/ui/project-insert/ProjectTeamSizeCard.tsx new file mode 100644 index 0000000..d7ac141 --- /dev/null +++ b/src/entities/projects/ui/project-insert/ProjectTeamSizeCard.tsx @@ -0,0 +1,66 @@ +import { FormControl, Select, MenuItem } from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import type { CSSProperties, JSX } from "react"; + +import SimpleFormCard from "@shared/ui/project-insert/SimpleFormCard"; + +interface ProjectTeamSizeCardProps { + value: number; // 받을 때는 number + onChange: (e: SelectChangeEvent) => void; // 변경할 때는 string으로 받기 + large?: boolean; + style?: CSSProperties; +} + +const ProjectTeamSizeCard = ({ + value, + onChange, + large, + style, +}: ProjectTeamSizeCardProps): JSX.Element => { + const theme = useTheme(); + + return ( + + + + value={value ? value.toString() : ""} + onChange={onChange} + size={large ? "medium" : "small"} + displayEmpty + sx={{ + fontSize: large + ? theme.typography.h5.fontSize + : theme.typography.body1.fontSize, + fontFamily: theme.typography.fontFamily, + padding: large ? theme.spacing(2.2) : theme.spacing(1.7), + + height: 40, + "& .MuiSelect-select": { + height: "40px", + display: "flex", + alignItems: "center", + padding: 0, + }, + }} + > + + 2명 (나 + 1명) + 3명 (소규모 팀) + 4명 (적당한 팀) + 5명 (큰 팀) + 6명 이상 (대규모) + + + + ); +}; + +export default ProjectTeamSizeCard; diff --git a/src/entities/projects/ui/project-insert/ProjectTechStackCard.tsx b/src/entities/projects/ui/project-insert/ProjectTechStackCard.tsx new file mode 100644 index 0000000..8895a2b --- /dev/null +++ b/src/entities/projects/ui/project-insert/ProjectTechStackCard.tsx @@ -0,0 +1,164 @@ +import AddIcon from "@mui/icons-material/Add"; +import { Box, Button } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import type { + ChangeEvent, + CSSProperties, + JSX, + KeyboardEvent, + FocusEvent, + MouseEvent, +} from "react"; +import { useState } from "react"; + +import SimpleFormCard from "@shared/ui/project-insert/SimpleFormCard"; + +interface ProjectTechStackCardProps { + value: string[]; + onChange: (value: string[]) => void; + large?: boolean; + style?: CSSProperties; +} + +const ProjectTechStackCard = ({ + value, + onChange, + large, + style, +}: ProjectTechStackCardProps): JSX.Element => { + const theme = useTheme(); + const [newTech, setNewTech] = useState(""); + + const addTech = (): void => { + if (newTech.trim() && !value.includes(newTech.trim())) { + onChange([...value, newTech.trim()]); + setNewTech(""); + } + }; + + const removeTech = (techToRemove: string): void => { + onChange(value.filter((tech) => tech !== techToRemove)); + }; + + const handleKeyPress = (e: KeyboardEvent): void => { + if (e.key === "Enter") { + e.preventDefault(); + addTech(); + } + }; + + return ( + + {/* 입력 + 추가 버튼 */} + + ) => + setNewTech(e.target.value) + } + onKeyPress={handleKeyPress} + placeholder="React, Python, Figma... 뭐든 좋아요!" + style={{ + flex: 1, + height: 40, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + fontSize: large + ? theme.typography.h5.fontSize + : theme.typography.body1.fontSize, + fontFamily: theme.typography.fontFamily, + background: theme.palette.background.paper, + padding: large ? theme.spacing(2.2) : theme.spacing(1.7), + boxSizing: "border-box", + outline: "none", + transition: "border-color 0.2s ease-in-out", + }} + onFocus={(e) => { + // 포커스 시: 파란색 테두리 + 두껍게 + e.target.style.borderColor = theme.palette.primary.main; + e.target.style.borderWidth = "2px"; + }} + onBlur={(e: FocusEvent) => { + e.currentTarget.style.borderColor = theme.palette.divider; + e.currentTarget.style.borderWidth = "1px"; + }} + onMouseEnter={(e: MouseEvent) => { + if (e.currentTarget !== document.activeElement) { + e.currentTarget.style.borderColor = "#000000"; + } + }} + onMouseLeave={(e: MouseEvent) => { + if (e.currentTarget !== document.activeElement) { + e.currentTarget.style.borderColor = theme.palette.divider; + } + }} + /> + + + + {/* 기술 태그들 */} + {value.length > 0 && ( + + {value.map((tech, index) => ( + + {tech} + removeTech(tech)} + sx={{ + width: 18, + height: 18, + borderRadius: "100%", + border: "none", + color: theme.palette.text.primary, + backgroundColor: "transparent", + cursor: "pointer", + fontSize: "18px", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + × + + + ))} + + )} + + ); +}; + +export default ProjectTechStackCard; diff --git a/src/features/projects/hook/useProjectInsertForm.ts b/src/features/projects/hook/useProjectInsertForm.ts index 71d129c..c9cbb0a 100644 --- a/src/features/projects/hook/useProjectInsertForm.ts +++ b/src/features/projects/hook/useProjectInsertForm.ts @@ -17,10 +17,17 @@ type Setp1Type = Pick< ProjectItemInsertReq, "title" | "oneLineInfo" | "category" | "closedDate" | "simpleInfo" >; +export type Step2Type = Pick< + ProjectItemInsertReq, + "teamSize" | "techStack" | "positions" +> & { + expectedPeriod: ExpectedPeriod | ""; +}; interface InsertFormResult { form: { step1: Setp1Type; + step2: Step2Type; }; page: { currentStep: number; @@ -28,16 +35,36 @@ interface InsertFormResult { goNext: () => void; }; submit: () => Promise; + onChange: { + step2: (field: keyof Step2Type, value: any) => void; + }; } const useProjectInsertForm = (): InsertFormResult => { const { mutate: insertItem, isPending } = useProjectInsert(); const [currentStep, setCurrentStep] = useState(1); + // Step1 상태 // const [formStep1, setFormStep1] = useState(initForm1); + // Step2 상태 + const [formStep2, setFormStep2] = useState({ + teamSize: 0, + expectedPeriod: "", + techStack: [], + positions: [], + }); + const handleChangeStep2 = (field: keyof Step2Type, value: any): void => { + setFormStep2((prev) => ({ ...prev, [field]: value })); + }; - const handlePrev = (): void => setCurrentStep((prev) => prev - 1); - const handleNext = (): void => setCurrentStep((prev) => prev + 1); + const handlePrev = (): void => { + setCurrentStep((prev) => prev - 1); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + const handleNext = (): void => { + setCurrentStep((prev) => prev + 1); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; const submit = async (): Promise => { if (!window.confirm("등록을 완료 하시겠습니까?")) return; @@ -49,6 +76,7 @@ const useProjectInsertForm = (): InsertFormResult => { return { form: { step1: initForm1, + step2: formStep2, }, page: { currentStep: currentStep, @@ -56,6 +84,9 @@ const useProjectInsertForm = (): InsertFormResult => { goNext: handleNext, }, submit, + onChange: { + step2: handleChangeStep2, + }, }; }; diff --git a/src/features/projects/ui/project-insert/Step2.tsx b/src/features/projects/ui/project-insert/Step2.tsx index 6fdd06d..61bd021 100644 --- a/src/features/projects/ui/project-insert/Step2.tsx +++ b/src/features/projects/ui/project-insert/Step2.tsx @@ -1,20 +1,67 @@ -import { Box, styled } from "@mui/material"; +import { Box, styled, useMediaQuery, useTheme } from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material"; import type { JSX } from "react"; -const Step2 = (): JSX.Element => { - return Step2; +import type { Step2Type } from "@features/projects/hook/useProjectInsertForm"; + +import ProjectExpectedPeriodCard from "@entities/projects/ui/project-insert/ProjectExpectedPeriodCard"; +import ProjectPositionsCard from "@entities/projects/ui/project-insert/ProjectPositionsCard"; +import ProjectTeamSizeCard from "@entities/projects/ui/project-insert/ProjectTeamSizeCard"; +import ProjectTechStackCard from "@entities/projects/ui/project-insert/ProjectTechStackCard"; + +interface Step2Props { + form: Step2Type; + onChangeForm: (field: keyof Step2Type, value: any) => void; +} + +const Step2 = ({ form, onChangeForm }: Step2Props): JSX.Element => { + const theme = useTheme(); + const isMdDown = useMediaQuery(theme.breakpoints.down("md")); + + return ( + + + onChangeForm("teamSize", Number(e.target.value)) + } + large + style={{ gridColumn: "span 1" }} + /> + + onChangeForm("expectedPeriod", e.target.value) + } + large + style={{ gridColumn: "span 1" }} + /> + onChangeForm("techStack", value)} + large + style={{ gridColumn: isMdDown ? "span 1" : "1 / -1" }} + /> + onChangeForm("positions", value)} + large + style={{ gridColumn: isMdDown ? "span 1" : "1 / -1" }} + /> + + ); }; export default Step2; export const StepBox = styled(Box)(({ theme }) => ({ display: "grid", - gridTemplateColumns: "1fr", // 기본값(xs) - gap: theme.spacing(2), // 기본값(sm 이하) + gridTemplateColumns: "1fr", + gap: theme.spacing(2), marginBottom: 0, [theme.breakpoints.up("md")]: { gridTemplateColumns: "1fr 1fr", - gap: theme.spacing(4), + gap: theme.spacing(3), }, })); diff --git a/src/pages/project-insert/ui/ProjectInsertPage.tsx b/src/pages/project-insert/ui/ProjectInsertPage.tsx index abd0b33..dd753ea 100644 --- a/src/pages/project-insert/ui/ProjectInsertPage.tsx +++ b/src/pages/project-insert/ui/ProjectInsertPage.tsx @@ -17,7 +17,7 @@ import Step3 from "@features/projects/ui/project-insert/Step3"; import Step4 from "@features/projects/ui/project-insert/Step4"; const ProjectInsertPage = (): JSX.Element => { - const { form, page, submit } = useProjectInsert(); + const { form, page, submit, onChange } = useProjectInsert(); return ( @@ -29,7 +29,9 @@ const ProjectInsertPage = (): JSX.Element => { {/* Step별 컴포넌트 */} {page.currentStep === 1 && } - {page.currentStep === 2 && } + {page.currentStep === 2 && ( + + )} {page.currentStep === 3 && } {page.currentStep === 4 && } diff --git a/src/pages/project-insert/ui/StepBox.tsx b/src/pages/project-insert/ui/StepBox.tsx index 0ef745d..b35307b 100644 --- a/src/pages/project-insert/ui/StepBox.tsx +++ b/src/pages/project-insert/ui/StepBox.tsx @@ -1,9 +1,8 @@ import { Box, styled, Typography } from "@mui/material"; import type { JSX } from "react"; -// 현재는 setps를 객체로 된 배열로 관리하지만 단순한 스타일의 반복이니 -// JS로 탐색 후 뿌려주는 것 보단 공통 스타일 컴포넌트를 생성하여 구성하는 것이 더 좋아보입니다! -// 추후에 여유가 될 때 리팩토링 해주시면 좋을거같습니다 ~ +// TODO: JS로 탐색 후 뿌려주는 방법 -> 공통 스타일 컴포넌트 생성하여 구성 처리 예정 + const steps = [ { id: 1, title: "기본 정보" }, { id: 2, title: "팀 구성" }, @@ -11,54 +10,133 @@ const steps = [ { id: 4, title: "모집 조건" }, ]; -// 스타일이 많아 코드 가독성 + 추후 재사용성을 고려하여 styled 컴포넌트로 빼내었습니다. -// 이렇게 하면 굳이 PRIMARY를 변수로 선언하지 않아도 theme에 저장된 색상을 불러올 수 있씁니다! -// key는 배열의 idx로 설정해 두었으며(가장 간단) -// '->'를 표시하기위한 삼항연산자도 간결하게 수정하였습니다 -// 여기 또한 Typography에 fontSize={number} 설정은 적용되지 않으므로 삭제하엿습니다 - const StepBox = ({ currentStep }: { currentStep: number }): JSX.Element => { return ( - - {steps.map((step, idx) => ( - - {step.id} - {step.title} - {idx !== 3 && ( - - → - - )} - - ))} - + + + {steps.map((step, idx) => ( + + + = step.id}>{step.id} + {idx !== steps.length - 1 && ( + step.id} /> + )} + + {step.title} + + ))} + + ); }; export default StepBox; +const StepContainer = styled(Box)(() => ({ + display: "flex", + justifyContent: "center", + marginBottom: 40, +})); + +const StepWrapper = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "flex-start", + justifyContent: "space-between", + maxWidth: 480, + width: "100%", + + [theme.breakpoints.up("sm")]: { + maxWidth: 540, + }, + + [theme.breakpoints.up("md")]: { + maxWidth: 600, + }, +})); + +const StepItem = styled(Box)(() => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + flex: 1, + position: "relative", +})); + +const StepCircleContainer = styled(Box)(() => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "100%", + position: "relative", +})); + const StepNumber = styled(Box, { - // active prop은 DOM에 전달되지 않도록 shouldForwardProp 필수 shouldForwardProp: (prop) => prop !== "active", })<{ active: boolean }>(({ active, theme }) => ({ display: "flex", alignItems: "center", justifyContent: "center", - width: 38, - height: 38, - fontSize: 18, + width: 40, + height: 40, + fontSize: 16, fontWeight: 700, color: active ? "#fff" : "#888", - background: active ? theme.palette.primary.main : "#e0e7ef", + background: active ? theme.palette.primary.main : "#fff", borderRadius: "50%", - border: `2px solid ${active ? theme.palette.primary.main : "#e0e7ef"}`, + border: `2px solid ${active ? theme.palette.primary.main : theme.palette.divider}`, + position: "relative", + zIndex: 2, + + [theme.breakpoints.up("sm")]: { + width: 45, + height: 45, + fontSize: 17, + }, + + [theme.breakpoints.up("md")]: { + width: 50, + height: 50, + fontSize: 18, + }, +})); + +const ConnectingLine = styled(Box, { + shouldForwardProp: (prop) => prop !== "active", +})<{ active: boolean }>(({ active, theme }) => ({ + position: "absolute", + left: "50%", + top: "50%", + transform: "translateY(-50%)", + width: "calc(100% - 20px)", + height: 2, + backgroundColor: active ? theme.palette.primary.main : theme.palette.divider, + zIndex: 1, + + [theme.breakpoints.up("sm")]: { + width: "calc(100% - 22px)", + }, + + [theme.breakpoints.up("md")]: { + width: "calc(100% - 25px)", + }, })); const StepName = styled(Typography, { shouldForwardProp: (prop) => prop !== "active", })<{ active: boolean }>(({ active, theme }) => ({ - marginLeft: theme.spacing(1), - marginRight: theme.spacing(1), + marginTop: theme.spacing(0.8), fontWeight: active ? 700 : 500, color: active ? theme.palette.primary.main : "#888", + textAlign: "center", + whiteSpace: "nowrap", + fontSize: "12px", + + [theme.breakpoints.up("sm")]: { + marginTop: theme.spacing(1), + fontSize: "13px", + }, + + [theme.breakpoints.up("md")]: { + fontSize: "14px", + }, })); diff --git a/src/pages/project-insert/ui/TopTitle.tsx b/src/pages/project-insert/ui/TopTitle.tsx index 617025f..89cb7d4 100644 --- a/src/pages/project-insert/ui/TopTitle.tsx +++ b/src/pages/project-insert/ui/TopTitle.tsx @@ -1,19 +1,26 @@ import { Box, Typography } from "@mui/material"; import type { JSX } from "react"; -// Box: maxWidth={700} mx="auto" 설정이 불필요하여 삭제히였습니다. -// Typography 엔 fontSize 설정이 적용되지 않아 삭제하였습니다. const TopTitle = (): JSX.Element => { return ( - - - 같이 할 사람 구해요! 🚀 + + + 프로젝트, 함께 시작해요 - - 멋진 아이디어가 있다면 팀원을 모집해보세요 - - - 혼자서는 힘들어도 함께라면 뭐든 할 수 있어요! + + 혼자서는 힘든 프로젝트도 팀과 함께라면 가능해요 +
+ 프로젝트잼에서 완벽한 팀원을 찾아보세요
); diff --git a/src/shared/ui/project-insert/SimpleFormCard.tsx b/src/shared/ui/project-insert/SimpleFormCard.tsx new file mode 100644 index 0000000..120d814 --- /dev/null +++ b/src/shared/ui/project-insert/SimpleFormCard.tsx @@ -0,0 +1,92 @@ +import { useTheme } from "@mui/material/styles"; +import type { CSSProperties, JSX, ReactNode } from "react"; + +interface SimpleFormCardProps { + icon?: string; + title: string; + description: string; + children?: ReactNode; + helpText?: string; + large?: boolean; + style?: CSSProperties; +} + +const SimpleFormCard = ({ + icon, + title, + description, + children, + helpText, + large, + style, +}: SimpleFormCardProps): JSX.Element => { + const theme = useTheme(); + + const cardStyles = { + container: { + background: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + padding: large ? theme.spacing(3) : theme.spacing(2), + minHeight: large ? 220 : 180, + display: "flex", + flexDirection: "column" as const, + justifyContent: "center", + width: "100%", + overflow: "visible", + fontFamily: theme.typography.fontFamily, + transition: "all 0.2s ease-in-out", + ...style, + } as CSSProperties, + + title: { + fontWeight: theme.typography.h3.fontWeight, + fontSize: large + ? theme.typography.h3.fontSize + : theme.typography.h4.fontSize, + marginBottom: 14, + color: theme.palette.text.primary, + display: "flex", + alignItems: "center", + } as CSSProperties, + + description: { + color: "#6f6f72", + fontSize: large + ? theme.typography.h5.fontSize + : theme.typography.body1.fontSize, + marginBottom: 18, + lineHeight: 1.5, + } as CSSProperties, + + helpText: { + color: "#bbb", + fontSize: large ? theme.typography.body1.fontSize : "1.2rem", + marginTop: 8, + } as CSSProperties, + + // children을 위한 컨테이너 스타일 + content: { + marginBottom: helpText ? 8 : 0, + } as CSSProperties, + }; + + return ( +
+
+ + {icon} + + {title} +
+ +
{description}
+ +
{children}
+ + {helpText &&
{helpText}
} +
+ ); +}; + +export default SimpleFormCard;