diff --git a/src/entities/user/ui/user-profile/ProjectTabPanel.tsx b/src/entities/user/ui/user-profile/ProjectTabPanel.tsx index 211e191..7124fae 100644 --- a/src/entities/user/ui/user-profile/ProjectTabPanel.tsx +++ b/src/entities/user/ui/user-profile/ProjectTabPanel.tsx @@ -1,4 +1,4 @@ -import { Box } from "@mui/material"; +import { Box, styled } from "@mui/material"; import type { JSX } from "react"; import type { ProjectListRes } from "@shared/types/project"; @@ -9,28 +9,45 @@ import EmptyProjectCard from "./EmptyProjectCard"; interface ProjectTabPanelProps { projects: ProjectListRes[]; emptyMessage: string; + editMode?: boolean; + selectedIds?: string[]; + onSelectProject?: (id: string) => void; } const ProjectTabPanel = ({ projects, emptyMessage, + editMode = false, + selectedIds = [], + onSelectProject, }: ProjectTabPanelProps): JSX.Element => projects && projects.length > 0 ? ( - - {projects.slice(0, 3).map((project) => ( - + + {projects.map((project) => ( + onSelectProject && onSelectProject(project.id)} + /> ))} - + ) : ( ); export default ProjectTabPanel; + +const StyledGridContainer = styled(Box)(({ theme }) => ({ + display: "grid", + gridTemplateColumns: "1fr", + gap: theme.spacing(2), + [theme.breakpoints.up("sm")]: { + gridTemplateColumns: "repeat(2, 1fr)", + }, + [theme.breakpoints.up("md")]: { + gridTemplateColumns: "repeat(3, 1fr)", + }, +})); diff --git a/src/entities/user/ui/user-profile/TapWithBadge.tsx b/src/entities/user/ui/user-profile/TapWithBadge.tsx index c43cbfe..63cc5bc 100644 --- a/src/entities/user/ui/user-profile/TapWithBadge.tsx +++ b/src/entities/user/ui/user-profile/TapWithBadge.tsx @@ -1,12 +1,12 @@ -import { Badge } from "@mui/material"; -import type { JSX } from "react"; +import { Badge, styled } from "@mui/material"; +import type { JSX, ComponentType } from "react"; interface TabWithBadgeProps { label: string; count: number; active: boolean; onClick: () => void; - ProfileTabChip: any; + ProfileTabChip: ComponentType; } const TabWithBadge = ({ @@ -16,22 +16,23 @@ const TabWithBadge = ({ onClick, ProfileTabChip, }: TabWithBadgeProps): JSX.Element => ( - - + ); export default TabWithBadge; + +const StyledBadge = styled(Badge)((_theme) => ({ + "& .MuiBadge-badge": { + fontSize: "1.1rem", + fontWeight: 700, + minWidth: 24, + height: 24, + }, +})); diff --git a/src/entities/user/ui/user-profile/UserProfileCard.tsx b/src/entities/user/ui/user-profile/UserProfileCard.tsx index 71ec1b7..a366c09 100644 --- a/src/entities/user/ui/user-profile/UserProfileCard.tsx +++ b/src/entities/user/ui/user-profile/UserProfileCard.tsx @@ -9,20 +9,21 @@ import { Divider, } from "@mui/material"; import { styled as muiStyled } from "@mui/material/styles"; -import type { JSX } from "react"; +import type { ComponentType, JSX } from "react"; + +import { useProjectStore } from "@shared/stores/projectStore"; +import type { User } from "@shared/types/user"; import TabWithBadge from "./TapWithBadge"; // Chip 컴포넌트는 상위에서 import해서 prop으로 넘겨야 함 interface UserProfileCardProps { - userProfile: any; + userProfile: User; PROFILE_TABS: { label: string; color: string }[]; - likeProjects: any[]; - appliedProjects: any[]; tab: number; setTab: (idx: number) => void; - ProfileTabChip: any; + ProfileTabChip: ComponentType; } const userRoleMap: Record = { @@ -33,48 +34,51 @@ const userRoleMap: Record = { pm: "PM", }; const experienceMap: Record = { - junior: "주니어 (3년 이하)", - mid: "미들 (3년 이상 10년 이하)", - senior: "시니어 (10년 이상)", + junior: "주니어 (3년 이하) 🌱", + mid: "미들 (3년 이상 10년 이하) 🌿", + senior: "시니어 (10년 이상) 🌳", }; const UserProfileCard = ({ userProfile, PROFILE_TABS, - likeProjects, - appliedProjects, tab, setTab, ProfileTabChip, }: UserProfileCardProps): JSX.Element => { + const { likeProjects, appliedProjects } = useProjectStore(); + return ( - - + + - + {userProfile.name} - + {userRoleMap[userProfile.userRole] || userProfile.userRole} - + {experienceMap[userProfile.experience] || userProfile.experience} - + {userProfile.introduceMyself} - {userProfile.email} - + {PROFILE_TABS.map((tabInfo, idx) => ( ))} - + + 💌 • {userProfile.email} ); @@ -100,15 +105,18 @@ export default UserProfileCard; // 스타일 컴포넌트 재사용 const ProfileCard = muiStyled(Card)(({ theme }) => ({ minWidth: 280, - maxWidth: 320, + maxWidth: "100%", borderRadius: 12, boxShadow: theme.shadows[2], position: "relative", - padding: 0, + padding: "0 2rem", + maxHeight: "350px", + overflow: "auto", })); const ProfileCardContent = muiStyled(CardContent)(({ theme }) => ({ padding: theme.spacing(3), paddingBottom: "16px", + position: "relative", "&:last-child": { paddingBottom: "16px", }, @@ -119,12 +127,18 @@ const ProfileCardHeader = muiStyled(Box)({ justifyContent: "flex-end", marginBottom: 8, minHeight: 32, + position: "absolute", + top: 10, + right: -10, }); const ProfileMainRow = muiStyled(Box)({ display: "flex", - flexDirection: "row", alignItems: "center", gap: 16, + justifyContent: "space-between", + flexDirection: "row-reverse", + marginTop: "3rem", + padding: "0 1rem", }); const ProfileAvatar = muiStyled(Avatar)({ width: 100, @@ -134,12 +148,23 @@ const ProfileAvatar = muiStyled(Avatar)({ const ProfileInfoCol = muiStyled(Box)({ display: "flex", flexDirection: "column", - gap: 4, + gap: 2, }); const ProfileEmail = muiStyled(Typography)(({ theme }) => ({ color: theme.palette.text.disabled, - fontSize: "0.95rem", + fontSize: "1.1rem", fontStyle: "italic", fontWeight: 400, marginTop: theme.spacing(1), + textAlign: "end", +})); + +const TabBadgeContainer = muiStyled(Box)(({ theme }) => ({ + marginTop: theme.spacing(2), + gap: theme.spacing(1), + justifyContent: "center", + display: "none", + [theme.breakpoints.up("sm")]: { + display: "flex", + }, })); diff --git a/src/entities/user/ui/user-profile/UserProfileProjectList.tsx b/src/entities/user/ui/user-profile/UserProfileProjectList.tsx index 2fad7f8..f43edf5 100644 --- a/src/entities/user/ui/user-profile/UserProfileProjectList.tsx +++ b/src/entities/user/ui/user-profile/UserProfileProjectList.tsx @@ -1,44 +1,173 @@ -import { Box, Tabs, Tab } from "@mui/material"; +import { + Box, + Tabs, + Tab, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, +} from "@mui/material"; import type { JSX } from "react"; +import { useState, useCallback } from "react"; import ProjectTabPanel from "@entities/user/ui/user-profile/ProjectTabPanel"; -import type { ProjectListRes } from "@shared/types/project"; +import { useProjectStore } from "@shared/stores/projectStore"; +import DeleteButton from "@shared/ui/DeleteButton"; +import SnackbarAlert from "@shared/ui/SnackbarAlert"; interface UserProfileProjectListProps { PROFILE_TABS: { label: string; color: string }[]; tab: number; setTab: (idx: number) => void; - likeProjects: ProjectListRes[]; - appliedProjects: ProjectListRes[]; + onDeleteProjects: ( + type: "likeProjects" | "appliedProjects", + ids: string[] + ) => Promise; } const UserProfileProjectList = ({ PROFILE_TABS, tab, setTab, - likeProjects, - appliedProjects, + onDeleteProjects, }: UserProfileProjectListProps): JSX.Element => { + const [editMode, setEditMode] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [openDialog, setOpenDialog] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState(false); + + const { + likeProjects, + appliedProjects, + removeLikeProjects, + removeAppliedProjects, + } = useProjectStore(); + + const currentProjects = tab === 0 ? likeProjects : appliedProjects; + const allIds = currentProjects.map((p) => p.id); + const isAllSelected = + selectedIds.length === allIds.length && allIds.length > 0; + + const handleToggleAll = useCallback(() => { + setSelectedIds(isAllSelected ? [] : allIds); + }, [isAllSelected, allIds]); + + const handleSelectProject = useCallback((id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((pid) => pid !== id) : [...prev, id] + ); + }, []); + + const handleDelete = useCallback(() => setOpenDialog(true), []); + const handleCancelDelete = useCallback(() => setOpenDialog(false), []); + + const handleConfirmDelete = useCallback(async () => { + if (tab === 0) { + await onDeleteProjects("likeProjects", selectedIds); + removeLikeProjects(selectedIds); + } else { + await onDeleteProjects("appliedProjects", selectedIds); + removeAppliedProjects(selectedIds); + } + setOpenDialog(false); + setSelectedIds([]); + setSnackbarOpen(true); + }, [ + tab, + selectedIds, + onDeleteProjects, + removeLikeProjects, + removeAppliedProjects, + ]); + return ( - setTab(v)} sx={{ mb: 3 }}> - {PROFILE_TABS.map((tabInfo, _idx) => ( - - ))} - + {/* Tabs와 버튼을 한 줄에 배치 */} + + setTab(v)}> + {PROFILE_TABS.map((tabInfo, _idx) => ( + + ))} + + + {editMode ? ( + <> + + + 삭제 + + + + ) : ( + + )} + + {tab === 0 && ( )} {tab === 1 && ( )} + {/* 삭제 확인 다이얼로그 */} + + 정말로 삭제하시겠습니까? + + + 선택한 프로젝트를 삭제하면 되돌릴 수 없습니다. + + + + + 삭제 + + + setSnackbarOpen(false)} + message="선택한 프로젝트가 성공적으로 삭제되었습니다." + severity="success" + duration={2500} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + /> ); }; diff --git a/src/features/projects/api/projectsApi.ts b/src/features/projects/api/projectsApi.ts index 5c2c9b6..32139b2 100644 --- a/src/features/projects/api/projectsApi.ts +++ b/src/features/projects/api/projectsApi.ts @@ -156,3 +156,28 @@ export const updateUnLike = async ( }; } }; + +export type UserProjectField = "likeProjects" | "appliedProjects"; + +export const removeProjectsFromUser = async ( + uid: string, + type: UserProjectField, + projectIds: string[] +): Promise<{ success: boolean; error?: string }> => { + if (!uid || projectIds.length === 0) { + return { + success: false, + error: "유저 ID 또는 삭제할 프로젝트가 없습니다.", + }; + } + try { + const userRef = doc(db, "users", uid); + await updateDoc(userRef, { + [type]: arrayRemove(...projectIds), + }); + return { success: true }; + } catch (err) { + console.error(err); + return { success: false, error: "파이어베이스 업데이트 실패" }; + } +}; diff --git a/src/pages/user-profile/ui/UserNotFound.tsx b/src/pages/user-profile/ui/UserNotFound.tsx new file mode 100644 index 0000000..aec59d1 --- /dev/null +++ b/src/pages/user-profile/ui/UserNotFound.tsx @@ -0,0 +1,24 @@ +import { Box, Typography, Button } from "@mui/material"; +import type { JSX } from "react"; +import { useNavigate } from "react-router-dom"; + +import useCountdown from "@shared/hooks/useCountdown"; + +const UserNotFound = (): JSX.Element => { + const navigate = useNavigate(); + const count = useCountdown(3, () => navigate("/")); + + return ( + + + 😕 유저 정보를 찾을 수 없습니다. + + {count}초 후 홈으로 이동합니다. + + + ); +}; + +export default UserNotFound; diff --git a/src/pages/user-profile/ui/UserProfilePage.tsx b/src/pages/user-profile/ui/UserProfilePage.tsx index 232e6b7..99e2b46 100644 --- a/src/pages/user-profile/ui/UserProfilePage.tsx +++ b/src/pages/user-profile/ui/UserProfilePage.tsx @@ -1,9 +1,10 @@ import { Box, Container, Chip as MuiChip } from "@mui/material"; import { styled as muiStyled } from "@mui/material/styles"; import type { JSX } from "react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useUserProfile } from "@features/auth/hooks/useUserProfile"; +import { removeProjectsFromUser } from "@features/projects/api/projectsApi"; import { useProjectsByIds } from "@entities/projects/hook/useProjectsByIds"; import UserProfileCard from "@entities/user/ui/user-profile/UserProfileCard"; @@ -11,6 +12,10 @@ import UserProfileHeader from "@entities/user/ui/user-profile/UserProfileHeader" import UserProfileProjectList from "@entities/user/ui/user-profile/UserProfileProjectList"; import { useAuthStore } from "@shared/stores/authStore"; +import { useProjectStore } from "@shared/stores/projectStore"; +import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner"; + +import UserNotFound from "./UserNotFound"; // 탭 이름 상수 배열 const PROFILE_TABS = [ @@ -21,33 +26,56 @@ const PROFILE_TABS = [ const UserProfilePage = (): JSX.Element => { const { user } = useAuthStore(); const uid = user?.uid; - const { data: userProfile } = useUserProfile(uid ?? ""); + const { data: userProfile, isLoading } = useUserProfile(uid ?? ""); + + // zustand store 사용 + const { setLikeProjects, setAppliedProjects } = useProjectStore(); // 관심있는/지원한 프로젝트 id 배열 const likeIds = userProfile?.likeProjects ?? []; const appliedIds = userProfile?.appliedProjects ?? []; // 프로젝트 데이터 가져오기 - const { data: likeProjects } = useProjectsByIds(likeIds); - const { data: appliedProjects } = useProjectsByIds(appliedIds); - console.log(likeProjects); - console.log(appliedProjects); + const { data: likeProjectsData } = useProjectsByIds(likeIds); + const { data: appliedProjectsData } = useProjectsByIds(appliedIds); + + // zustand store에 동기화 + useEffect(() => { + if (likeProjectsData) setLikeProjects(likeProjectsData); + }, [likeProjectsData, setLikeProjects]); + useEffect(() => { + if (appliedProjectsData) setAppliedProjects(appliedProjectsData); + }, [appliedProjectsData, setAppliedProjects]); const [tab, setTab] = useState(0); + const handleDeleteProjects = async ( + type: "likeProjects" | "appliedProjects", + ids: string[] + ): Promise => { + if (!user) return; + await removeProjectsFromUser(user.uid, type, ids); + }; + + if (isLoading) { + return ; + } if (!userProfile) { - return
UserProfilePage
; + return ; } return ( - + {/* 왼쪽 프로필 사이드바 */} { PROFILE_TABS={PROFILE_TABS} tab={tab} setTab={setTab} - likeProjects={likeProjects ?? []} - appliedProjects={appliedProjects ?? []} + onDeleteProjects={handleDeleteProjects} /> diff --git a/src/shared/hooks/useCountdown.ts b/src/shared/hooks/useCountdown.ts new file mode 100644 index 0000000..86a206f --- /dev/null +++ b/src/shared/hooks/useCountdown.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +const useCountdown = (start: number, onEnd?: () => void): number => { + const [count, setCount] = useState(start); + + useEffect(() => { + if (count === 0) { + if (onEnd) onEnd(); + return; + } + const timer = setTimeout(() => setCount((prev) => prev - 1), 1000); + return () => clearTimeout(timer); + }, [count, onEnd]); + + return count; +}; + +export default useCountdown; diff --git a/src/shared/stores/projectStore.ts b/src/shared/stores/projectStore.ts new file mode 100644 index 0000000..5ff6c2a --- /dev/null +++ b/src/shared/stores/projectStore.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +import type { ProjectListRes } from "@shared/types/project"; + +interface ProjectStoreState { + likeProjects: ProjectListRes[]; + appliedProjects: ProjectListRes[]; + setLikeProjects: (projects: ProjectListRes[]) => void; + setAppliedProjects: (projects: ProjectListRes[]) => void; + removeLikeProjects: (ids: string[]) => void; + removeAppliedProjects: (ids: string[]) => void; +} + +export const useProjectStore = create((set) => ({ + likeProjects: [], + appliedProjects: [], + setLikeProjects: (projects) => set({ likeProjects: projects }), + setAppliedProjects: (projects) => set({ appliedProjects: projects }), + removeLikeProjects: (ids) => + set((state) => ({ + likeProjects: state.likeProjects.filter((p) => !ids.includes(p.id)), + })), + removeAppliedProjects: (ids) => + set((state) => ({ + appliedProjects: state.appliedProjects.filter((p) => !ids.includes(p.id)), + })), +})); diff --git a/src/shared/ui/DeleteButton.tsx b/src/shared/ui/DeleteButton.tsx new file mode 100644 index 0000000..cb23fad --- /dev/null +++ b/src/shared/ui/DeleteButton.tsx @@ -0,0 +1,41 @@ +import DeleteIcon from "@mui/icons-material/Delete"; +import { Button, styled } from "@mui/material"; +import type { JSX } from "react"; + +interface DeleteButtonProps { + onClick: () => void; + children?: React.ReactNode; + sx?: any; + disabled?: boolean; +} + +const DeleteButton = ({ + onClick, + children = "삭제", + sx, + disabled = false, +}: DeleteButtonProps): JSX.Element => ( + } + onClick={onClick} + sx={sx} + disabled={disabled} + > + {children} + +); + +export default DeleteButton; + +const StyledButton = styled(Button)(({ theme }) => ({ + fontWeight: 600, + borderRadius: theme.spacing(0.8), + boxShadow: "none", + color: theme.palette.error.main, + borderColor: theme.palette.error.main, + "&:hover": { + backgroundColor: theme.palette.error.light, + color: theme.palette.error.contrastText, + }, +})); diff --git a/src/shared/ui/ProjectCard.tsx b/src/shared/ui/ProjectCard.tsx index 5b68c63..dc5118d 100644 --- a/src/shared/ui/ProjectCard.tsx +++ b/src/shared/ui/ProjectCard.tsx @@ -14,6 +14,7 @@ import { useMediaQuery, useTheme, } from "@mui/material"; +import Checkbox from "@mui/material/Checkbox"; import type { JSX } from "react"; import { memo } from "react"; import { Link } from "react-router-dom"; @@ -31,12 +32,18 @@ interface ProjectCardProps { project: ProjectListRes; simple?: boolean; sx?: any; + editMode?: boolean; + selected?: boolean; + onSelect?: () => void; } const ProjectCard = ({ project, simple = false, sx, + editMode = false, + selected = false, + onSelect, }: ProjectCardProps): JSX.Element => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.up("sm")); @@ -46,6 +53,9 @@ const ProjectCard = ({ + {simple && editMode && ( + + )} @@ -172,7 +182,7 @@ const StyledCardContent = styled(CardContent)(({ theme }) => ({ const ProjectHeader = styled(Box)(() => ({ display: "flex", - justifyContent: "flex-start", + justifyContent: "space-between", alignItems: "center", })); diff --git a/src/shared/ui/SnackbarAlert.tsx b/src/shared/ui/SnackbarAlert.tsx new file mode 100644 index 0000000..80d174b --- /dev/null +++ b/src/shared/ui/SnackbarAlert.tsx @@ -0,0 +1,38 @@ +import { + Snackbar, + Alert, + type AlertColor, + type SnackbarOrigin, +} from "@mui/material"; +import type { JSX } from "react"; + +interface SnackbarAlertProps { + open: boolean; + onClose: () => void; + message: string; + severity?: AlertColor; + duration?: number; + anchorOrigin?: SnackbarOrigin; +} + +const SnackbarAlert = ({ + open, + onClose, + message, + severity = "success", + duration = 2500, + anchorOrigin = { vertical: "top", horizontal: "center" }, +}: SnackbarAlertProps): JSX.Element => ( + + + {message} + + +); + +export default SnackbarAlert;