From d796a8be1dbdd60a9be9e68f3d83ff2a10bb966a Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 9 Feb 2026 21:44:39 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20notification=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/darak/community/page.tsx | 10 +- client/src/app/admin/page.tsx | 14 +- client/src/app/admin/soon/page.tsx | 18 +- client/src/app/admin/worshipSchedule/page.tsx | 20 +- client/src/app/common/login/page.tsx | 6 +- .../app/event/worshipContest/vote/page.tsx | 37 ++-- client/src/app/leader/all-attendance/page.tsx | 11 +- client/src/app/leader/management/page.tsx | 9 +- .../app/leader/newcomer/education/page.tsx | 8 +- .../app/leader/newcomer/management/page.tsx | 27 ++- .../src/app/leader/newcomer/managers/page.tsx | 21 +- client/src/app/leader/postcard/page.tsx | 7 +- .../src/app/retreat/admin/carpooling/page.tsx | 7 +- client/src/app/retreat/steps/fifthStep.tsx | 9 +- client/src/app/retreat/steps/fourth.tsx | 12 +- .../components/form/UserInformationForm.tsx | 23 +-- .../components/notification/notification.tsx | 183 ++++++++++++------ client/src/hooks/useAuth.ts | 10 +- client/src/hooks/useNotification.ts | 61 ++++++ client/src/state/notification.ts | 14 +- 20 files changed, 316 insertions(+), 191 deletions(-) create mode 100644 client/src/hooks/useNotification.ts diff --git a/client/src/app/admin/darak/community/page.tsx b/client/src/app/admin/darak/community/page.tsx index cc8149e..833fd39 100644 --- a/client/src/app/admin/darak/community/page.tsx +++ b/client/src/app/admin/darak/community/page.tsx @@ -7,7 +7,7 @@ import CommunityCard from "./CommunityCard" import { dele, get, post, put } from "@/config/api" import { MouseEvent, useEffect, useState } from "react" import { type Community } from "@server/entity/community" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import CommunityControlPanel from "./CommunityControlPanel" enum EditMode { @@ -32,7 +32,7 @@ export default function CommunityComponent() { const [clickedCommunityName, setClickedCommunityName] = useState("") const [isMoveMode, setIsMoveMode] = useState(false) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { fetchData() @@ -77,7 +77,7 @@ export default function CommunityComponent() { const canAdd = checkLoopReference(selectedCommunity, parentCommunity.id) if (!canAdd) { if (selectedCommunity.id !== parentCommunity.id) { - setNotificationMessage(`순환 참조 입니다.`) + error(`순환 참조 입니다.`) } setSelectedCommunity(undefined) return @@ -96,7 +96,7 @@ export default function CommunityComponent() { function checkLoopReference( checkCommunity: Community, - parentId: number + parentId: number, ): boolean { if (checkCommunity.id === parentId) return false const childList = communityList.filter((community) => { @@ -164,7 +164,7 @@ export default function CommunityComponent() { selectedCommunity={selectedCommunity} onEditModeChange={() => setEditMode( - editMode === EditMode.All ? EditMode.Folder : EditMode.All + editMode === EditMode.All ? EditMode.Folder : EditMode.All, ) } onAddCommunity={addCommunity} diff --git a/client/src/app/admin/page.tsx b/client/src/app/admin/page.tsx index 15fe4a1..04ac897 100644 --- a/client/src/app/admin/page.tsx +++ b/client/src/app/admin/page.tsx @@ -19,7 +19,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import PeopleIcon from "@mui/icons-material/People" import EventNoteIcon from "@mui/icons-material/EventNote" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import TrendingUpIcon from "@mui/icons-material/TrendingUp" import CalendarTodayIcon from "@mui/icons-material/CalendarToday" @@ -70,10 +70,10 @@ interface DashboardData { function index() { const router = useRouter() const { isLogin, authUserData } = useAuth() - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error: showError } = useNotification() const [dashboardData, setDashboardData] = useState(null) const [loading, setLoading] = useState(true) - const [error, setError] = useState("") + const [errorMsg, setErrorMsg] = useState("") useEffect(() => { hasPermission() @@ -89,7 +89,7 @@ function index() { if (!isLogin || !authUserData) { router.push("/common/login?returnUrl=/admin") } else if (!authUserData.role.Admin) { - setNotificationMessage("관리자 권한이 없습니다.") + showError("관리자 권한이 없습니다.") router.push("/") } else { setLoading(false) @@ -101,7 +101,7 @@ function index() { const { data } = await axios.get("/admin/dashboard") setDashboardData(data) } catch (err) { - setError("대시보드 데이터를 불러오는 중 오류가 발생했습니다.") + setErrorMsg("대시보드 데이터를 불러오는 중 오류가 발생했습니다.") console.error("Dashboard fetch error:", err) } } @@ -124,7 +124,7 @@ function index() { ) } - if (error) { + if (errorMsg) { return ( - {error} + {errorMsg} diff --git a/client/src/app/admin/soon/page.tsx b/client/src/app/admin/soon/page.tsx index 6a126eb..537dbe2 100644 --- a/client/src/app/admin/soon/page.tsx +++ b/client/src/app/admin/soon/page.tsx @@ -8,7 +8,7 @@ import UserFilter from "./UserFilter" import UserTable from "./UserTable" import UserForm from "./UserForm" import { get, post, put, dele } from "@/config/api" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" const emptyUser = { id: "", @@ -24,7 +24,7 @@ export default function Soon() { const [selectedUser, setSelectedUsers] = useState(emptyUser) const [orderProperty, setOrderProperty] = useState("name") const [direction, setDirection] = useState<"asc" | "desc">("asc") - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() // 필터 상태 const [filterName, setFilterName] = useState("") @@ -53,14 +53,14 @@ export default function Soon() { try { if (selectedUser.id) { await put("/admin/soon/update-user", selectedUser) - setNotificationMessage("사용자 정보가 수정되었습니다.") + success("사용자 정보가 수정되었습니다.") } else { await post("/admin/soon/insert-user", selectedUser) - setNotificationMessage("새 사용자가 추가되었습니다.") + success("새 사용자가 추가되었습니다.") } await fetchData() - } catch (error) { - setNotificationMessage("저장 중 오류가 발생했습니다.") + } catch (err) { + error("저장 중 오류가 발생했습니다.") } } @@ -68,11 +68,11 @@ export default function Soon() { if (selectedUser.id && confirm("정말로 삭제하시겠습니까?")) { try { await dele(`/admin/soon/delete-user/${selectedUser.id}`, {}) - setNotificationMessage("사용자가 삭제되었습니다.") + success("사용자가 삭제되었습니다.") clearSelectedUser() await fetchData() - } catch (error) { - setNotificationMessage("삭제 중 오류가 발생했습니다.") + } catch (err) { + error("삭제 중 오류가 발생했습니다.") } } } diff --git a/client/src/app/admin/worshipSchedule/page.tsx b/client/src/app/admin/worshipSchedule/page.tsx index b460304..bab9c96 100644 --- a/client/src/app/admin/worshipSchedule/page.tsx +++ b/client/src/app/admin/worshipSchedule/page.tsx @@ -29,24 +29,24 @@ import EditIcon from "@mui/icons-material/Edit" import SaveIcon from "@mui/icons-material/Save" import DeleteIcon from "@mui/icons-material/Delete" import SearchIcon from "@mui/icons-material/Search" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import CalendarMonthIcon from "@mui/icons-material/CalendarMonth" import { WorshipKind, WorshipSchedule } from "@server/entity/worshipSchedule" import axios from "@/config/axios" import dayjs from "dayjs" export default function WorshipSchedulePage() { - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() const [worshipScheduleList, setWorshipScheduleList] = useState< WorshipSchedule[] >([]) // Filter lists const [filterStartDate, setFilterStartDate] = useState( - dayjs().add(-1, "month").format("YYYY-MM-DD") + dayjs().add(-1, "month").format("YYYY-MM-DD"), ) const [filterEndDate, setFilterEndDate] = useState( - dayjs().format("YYYY-MM-DD") + dayjs().format("YYYY-MM-DD"), ) const [filterKind, setFilterKind] = useState("") const [filterCanEdit, setFilterCanEdit] = useState("") @@ -80,14 +80,14 @@ export default function WorshipSchedulePage() { const { data: worshipScheduleList } = await axios.get( "/admin/worship-schedule", - { params } + { params }, ) setWorshipScheduleList(worshipScheduleList) } async function saveWorshipSchedule() { if (!selectedWorship.date || !selectedWorship.kind) { - setNotificationMessage("날짜와 종류를 모두 입력해주세요.") + error("날짜와 종류를 모두 입력해주세요.") return } if (selectedWorship.id) { @@ -95,7 +95,7 @@ export default function WorshipSchedulePage() { } else { await axios.post("/admin/worship-schedule", selectedWorship) } - setNotificationMessage("예배 일정이 저장되었습니다.") + success("예배 일정이 저장되었습니다.") await fetchWorshipSchedules() } @@ -111,7 +111,7 @@ export default function WorshipSchedulePage() { async function deleteWorshipSchedule() { if (!selectedWorship.id) return await axios.delete(`/admin/worship-schedule/${selectedWorship.id}`) - setNotificationMessage("예배 일정이 삭제되었습니다.") + success("예배 일정이 삭제되었습니다.") await fetchWorshipSchedules() } @@ -247,7 +247,7 @@ export default function WorshipSchedulePage() { .sort( (a, b) => new Date(b.date).getTime() - - new Date(a.date).getTime() + new Date(a.date).getTime(), ) .map((schedule) => ( editWorshipSchedule( "isVisible", - e.target.value === "true" + e.target.value === "true", ) } size="small" diff --git a/client/src/app/common/login/page.tsx b/client/src/app/common/login/page.tsx index 6a8de1f..ca158f0 100644 --- a/client/src/app/common/login/page.tsx +++ b/client/src/app/common/login/page.tsx @@ -5,7 +5,7 @@ import { useEffect } from "react" import { useSetAtom } from "jotai" import useAuth from "@/hooks/useAuth" import { Button, Stack } from "@mui/material" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import { useRouter, useSearchParams } from "next/navigation" import useKakaoHook from "@/hooks/useKakao" @@ -22,7 +22,7 @@ function Login() { const searchParams = useSearchParams() const { getKakaoTokenFromAuthCode, login, isLogin } = useAuth() const { executeKakaoLogin } = useKakaoHook() - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { const code = searchParams.get("code") @@ -45,7 +45,7 @@ function Login() { const returnUrl = searchParams.get("returnUrl") || "/" await executeKakaoLogin(returnUrl) } catch (error) { - setNotificationMessage("카카오 로그인 실패") + error("카카오 로그인 실패") } } diff --git a/client/src/app/event/worshipContest/vote/page.tsx b/client/src/app/event/worshipContest/vote/page.tsx index a2d20b8..fe8580f 100644 --- a/client/src/app/event/worshipContest/vote/page.tsx +++ b/client/src/app/event/worshipContest/vote/page.tsx @@ -13,8 +13,7 @@ import { Avatar, } from "@mui/material" import useAuth from "@/hooks/useAuth" -import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import { useRouter } from "next/navigation" const 마을들: Record = { @@ -84,7 +83,7 @@ export default function VotePage() { const [firstCommunity, setFirstCommunity] = useState("") const [secondCommunity, setSecondCommunity] = useState("") const [thirdCommunity, setThirdCommunity] = useState("") - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() const { push } = useRouter() useEffect(() => { @@ -97,7 +96,7 @@ export default function VotePage() { const { data } = await axios.get("/event/worship-contest/status") setState(data.currentVoteStatus) if (data.currentVoteStatus === "투표불가") { - setNotificationMessage(`현재 투표가 불가능합니다.`) + error(`현재 투표가 불가능합니다.`) push("/event/worshipContest/main") } } @@ -106,17 +105,15 @@ export default function VotePage() { try { const { data } = await axios.get("/event/worship-contest/my-village") setMyVillage(data.communityName) - } catch (error) { - setNotificationMessage( - "마을 정보를 불러오지 못했습니다.\n순장님에게 문의하세요." - ) + } catch (err) { + error("마을 정보를 불러오지 못했습니다.\n순장님에게 문의하세요.") push("/event/worshipContest/main") } } async function submitVote() { const confirmed = confirm( - "제출한 투표는 수정할 수 없습니다. 제출하시겠습니까?" + "제출한 투표는 수정할 수 없습니다. 제출하시겠습니까?", ) if (!confirmed) { return @@ -128,9 +125,9 @@ export default function VotePage() { thirdCommunity, state, }) - setNotificationMessage("투표가 완료되었습니다.") - } catch (error) { - setNotificationMessage(error.response.data.message) + success("투표가 완료되었습니다.") + } catch (err: any) { + error(err.response?.data?.message || "투표 중 오류가 발생했습니다.") return } } @@ -354,28 +351,28 @@ export default function VotePage() { state === "1부 투표" ? "linear-gradient(90deg, #b3e0f2 0%, #e0f7fa 100%)" : state === "2부 투표" - ? "linear-gradient(90deg, #f8bbd0 0%, #fce4ec 100%)" - : undefined, + ? "linear-gradient(90deg, #f8bbd0 0%, #fce4ec 100%)" + : undefined, color: "#444", boxShadow: state === "1부 투표" ? "0 2px 8px rgba(115,174,180,0.10)" : state === "2부 투표" - ? "0 2px 8px rgba(239,160,174,0.10)" - : undefined, + ? "0 2px 8px rgba(239,160,174,0.10)" + : undefined, "&:hover": { background: state === "1부 투표" ? "linear-gradient(90deg, #e0f7fa 0%, #b3e0f2 100%)" : state === "2부 투표" - ? "linear-gradient(90deg, #fce4ec 0%, #f8bbd0 100%)" - : undefined, + ? "linear-gradient(90deg, #fce4ec 0%, #f8bbd0 100%)" + : undefined, boxShadow: state === "1부 투표" ? "0 4px 16px rgba(115,174,180,0.15)" : state === "2부 투표" - ? "0 4px 16px rgba(239,160,174,0.15)" - : undefined, + ? "0 4px 16px rgba(239,160,174,0.15)" + : undefined, }, }} onClick={submitVote} diff --git a/client/src/app/leader/all-attendance/page.tsx b/client/src/app/leader/all-attendance/page.tsx index dccfeab..d27947c 100644 --- a/client/src/app/leader/all-attendance/page.tsx +++ b/client/src/app/leader/all-attendance/page.tsx @@ -18,13 +18,12 @@ import { import CommunityBox from "@/app/admin/soon/attendance/CommunityBox" import useAuth from "@/hooks/useAuth" import { useRouter } from "next/navigation" -import { useAtom, useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" export default function AttendanceAdminPage() { const [communities, setCommunities] = useState([]) const [selectedCommunity, setSelectedCommunity] = useState( - null + null, ) const [communityStack, setCommunityStack] = useState([]) const [soonList, setSoonList] = useState([]) @@ -35,12 +34,12 @@ export default function AttendanceAdminPage() { const { authUserData } = useAuth() const { push } = useRouter() - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { fetchCommunities() if (!authUserData?.role.VillageLeader) { - setNotificationMessage("접근 권한이 없습니다.") + error("접근 권한이 없습니다.") push("/leader") } }, []) @@ -114,7 +113,7 @@ export default function AttendanceAdminPage() { const map: WorshipSchedule[] = [] attendDataList.forEach((data) => { const existing = map.find( - (worshipSchedule) => worshipSchedule.id === data.worshipSchedule.id + (worshipSchedule) => worshipSchedule.id === data.worshipSchedule.id, ) if (existing) { return diff --git a/client/src/app/leader/management/page.tsx b/client/src/app/leader/management/page.tsx index f0a8253..2a7eedb 100644 --- a/client/src/app/leader/management/page.tsx +++ b/client/src/app/leader/management/page.tsx @@ -20,8 +20,7 @@ import AddUser from "./AddUser" import PersonAddIcon from "@mui/icons-material/PersonAdd" import PeopleIcon from "@mui/icons-material/People" import PhoneIcon from "@mui/icons-material/Phone" -import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import { useRouter } from "next/navigation" import RequestKakaoLogin from "@/app/leader/management/RequestKakaoLogin" import useAuth from "@/hooks/useAuth" @@ -32,7 +31,7 @@ export default function SoonManagement() { const [groupName, setGroupName] = useState("") const [soonList, setSoonList] = useState([]) const [openAddUser, setOpenAddUser] = useState(false) - const setNotificationMessage = useSetAtom(NotificationMessage) + const notification = useNotification() useEffect(() => { isLeaderIfNotExit("/leader/management") @@ -42,13 +41,13 @@ export default function SoonManagement() { async function fetchGroupDate() { try { const { data } = await axios.get( - "/soon/my-group-info" + "/soon/my-group-info", ) setGroupName(data.name) setSoonList(data.users) } catch (error: any) { console.error("Error fetching group data:", error) - setNotificationMessage("순장만 이용할 수 있습니다.") + notification.error("순장만 이용할 수 있습니다.") push("/") return } diff --git a/client/src/app/leader/newcomer/education/page.tsx b/client/src/app/leader/newcomer/education/page.tsx index 3b16193..5bcff30 100644 --- a/client/src/app/leader/newcomer/education/page.tsx +++ b/client/src/app/leader/newcomer/education/page.tsx @@ -14,8 +14,7 @@ import { } from "@mui/material" import { useEffect, useState } from "react" import useAuth from "@/hooks/useAuth" -import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" interface WorshipSchedule { id: number @@ -80,7 +79,7 @@ export default function NewcomerEducationPage() { ) const [loading, setLoading] = useState(true) const [savingCell, setSavingCell] = useState(null) // 저장 중인 셀 표시 - const setNotificationMessage = useSetAtom(NotificationMessage) + const notification = useNotification() useEffect(() => { isLeaderIfNotExit("/leader/newcomer/education") @@ -103,6 +102,7 @@ export default function NewcomerEducationPage() { }) } catch (error) { console.error("Error fetching education data:", error) + notification.error("교육 데이터를 불러오는 중 오류가 발생했습니다.") } finally { setLoading(false) } @@ -198,7 +198,7 @@ export default function NewcomerEducationPage() { }) } catch (error) { console.error("Error saving education data:", error) - setNotificationMessage("저장 중 오류가 발생했습니다.") + notification.error("저장 중 오류가 발생했습니다.") } finally { setSavingCell(null) } diff --git a/client/src/app/leader/newcomer/management/page.tsx b/client/src/app/leader/newcomer/management/page.tsx index ff8dde2..7f61eba 100644 --- a/client/src/app/leader/newcomer/management/page.tsx +++ b/client/src/app/leader/newcomer/management/page.tsx @@ -10,9 +10,8 @@ import { TextField, } from "@mui/material" import { useEffect, useState } from "react" -import { useSetAtom } from "jotai" import axios from "@/config/axios" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import useAuth from "@/hooks/useAuth" import NewcomerTable from "./NewcomerTable" import NewcomerFilter from "./NewcomerFilter" @@ -65,7 +64,7 @@ export default function NewcomerManagement() { useState(emptyNewcomer) const [orderProperty, setOrderProperty] = useState("name") const [direction, setDirection] = useState<"asc" | "desc">("asc") - const setNotificationMessage = useSetAtom(NotificationMessage) + const notification = useNotification() // 필터 상태 const [filterName, setFilterName] = useState("") @@ -97,7 +96,7 @@ export default function NewcomerManagement() { setManagerList(managerRes.data) } catch (error) { console.error("Error fetching data:", error) - setNotificationMessage("데이터 조회에 실패했습니다.") + notification.error("데이터 조회에 실패했습니다.") } } @@ -122,7 +121,7 @@ export default function NewcomerManagement() { assignmentId: selectedNewcomer.assignment?.id || null, status: selectedNewcomer.status, }) - setNotificationMessage("새신자 정보가 수정되었습니다.") + notification.success("새신자 정보가 수정되었습니다.") } else { await axios.post("/newcomer", { name: selectedNewcomer.name, @@ -131,12 +130,12 @@ export default function NewcomerManagement() { phone: selectedNewcomer.phone, newcomerManagerId: selectedNewcomer.newcomerManager?.id || null, }) - setNotificationMessage("새신자가 추가되었습니다.") + notification.success("새신자가 추가되었습니다.") } await fetchData() clearSelectedNewcomer() - } catch (error) { - setNotificationMessage("저장 중 오류가 발생했습니다.") + } catch (err) { + notification.error("저장 중 오류가 발생했습니다.") } } @@ -169,14 +168,14 @@ export default function NewcomerManagement() { async function handleDateConfirm() { if (!selectedDate) { - setNotificationMessage("날짜를 선택해주세요.") + notification.error("날짜를 선택해주세요.") return } try { if (dateDialogType === "delete") { await axios.delete(`/newcomer/${selectedNewcomer.id}`) - setNotificationMessage("새신자가 삭제되었습니다.") + notification.success("새신자가 삭제되었습니다.") clearSelectedNewcomer() } else if (dateDialogType === "pending") { await axios.put(`/newcomer/${selectedNewcomer.id}`, { @@ -184,22 +183,22 @@ export default function NewcomerManagement() { status: "PENDING", pendingDate: selectedDate, }) - setNotificationMessage("새신자가 보류 처리되었습니다.") + notification.success("새신자가 보류 처리되었습니다.") } else if (dateDialogType === "promotion") { await axios.put(`/newcomer/${selectedNewcomer.id}`, { ...selectedNewcomer, status: "PROMOTED", promotionDate: selectedDate, }) - setNotificationMessage("새신자가 등반 처리되었습니다.") + notification.success("새신자가 등반 처리되었습니다.") } setDateDialogOpen(false) setDateDialogType("") setSelectedDate("") await fetchData() - } catch (error) { - setNotificationMessage("처리 중 오류가 발생했습니다.") + } catch (err) { + notification.error("처리 중 오류가 발생했습니다.") } } diff --git a/client/src/app/leader/newcomer/managers/page.tsx b/client/src/app/leader/newcomer/managers/page.tsx index 90129a3..fe587fd 100644 --- a/client/src/app/leader/newcomer/managers/page.tsx +++ b/client/src/app/leader/newcomer/managers/page.tsx @@ -17,8 +17,7 @@ import { import { useEffect, useState } from "react" import axios from "@/config/axios" import useAuth from "@/hooks/useAuth" -import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import CloseIcon from "@mui/icons-material/Close" import PersonAddIcon from "@mui/icons-material/PersonAdd" @@ -47,7 +46,7 @@ export default function ManagerPage() { const [managerList, setManagerList] = useState([]) const [searchName, setSearchName] = useState("") const [loading, setLoading] = useState(true) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() useEffect(() => { isLeaderIfNotExit("/leader/newcomer/managers") @@ -73,11 +72,11 @@ export default function ManagerPage() { async function addManager(userId: string) { try { await axios.post("/newcomer/managers", { userId }) - setNotificationMessage("담당자로 지정되었습니다.") + success("담당자로 지정되었습니다.") await fetchData() - } catch (error) { - console.error("Error adding manager:", error) - setNotificationMessage("담당자 지정 중 오류가 발생했습니다.") + } catch (err) { + console.error("Error adding manager:", err) + error("담당자 지정 중 오류가 발생했습니다.") } } @@ -85,11 +84,11 @@ export default function ManagerPage() { if (!confirm("정말로 담당자를 해제하시겠습니까?")) return try { await axios.delete(`/newcomer/managers/${managerId}`) - setNotificationMessage("담당자가 해제되었습니다.") + success("담당자가 해제되었습니다.") await fetchData() - } catch (error) { - console.error("Error removing manager:", error) - setNotificationMessage("담당자 해제 중 오류가 발생했습니다.") + } catch (err) { + console.error("Error removing manager:", err) + error("담당자 해제 중 오류가 발생했습니다.") } } diff --git a/client/src/app/leader/postcard/page.tsx b/client/src/app/leader/postcard/page.tsx index 3081f4c..327522b 100644 --- a/client/src/app/leader/postcard/page.tsx +++ b/client/src/app/leader/postcard/page.tsx @@ -8,8 +8,7 @@ import { useEffect, useState } from "react" import Header from "@/app/leader/components/Header" import { useRouter } from "next/navigation" import axios from "@/config/axios" -import { useAtom, useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" export default function PostcardPage() { const { push } = useRouter() @@ -21,7 +20,7 @@ export default function PostcardPage() { userId: string text: string } | null>(null) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { success } = useNotification() useEffect(() => { fetchGroupDate() @@ -81,7 +80,7 @@ export default function PostcardPage() { targetUserId: selectedUser?.id, }) localStorage.removeItem("postcardData") - setNotificationMessage("저장되었습니다.") + success("저장되었습니다.") } return ( diff --git a/client/src/app/retreat/admin/carpooling/page.tsx b/client/src/app/retreat/admin/carpooling/page.tsx index 8578b69..8d832a4 100644 --- a/client/src/app/retreat/admin/carpooling/page.tsx +++ b/client/src/app/retreat/admin/carpooling/page.tsx @@ -4,8 +4,7 @@ import { get, post } from "@/config/api" import { useEffect, useState } from "react" import { Box, MenuItem, Select, Stack } from "@mui/material" import { useRouter } from "next/navigation" -import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import { Days, HowToMove, InOutType } from "@server/entity/types" import Header from "@/components/retreat/admin/Header" @@ -13,7 +12,7 @@ import { RetreatAttend } from "@server/entity/retreat/retreatAttend" function Carpooling() { const router = useRouter() - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() const [carList, setCarList] = useState([] as InOutInfo[]) const [rideUserList, setRideUserList] = useState([] as InOutInfo[]) const [selectedInfo, setSelectedInfo] = useState() @@ -106,7 +105,7 @@ function Carpooling() { }) .catch(() => { router.push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return }) } diff --git a/client/src/app/retreat/steps/fifthStep.tsx b/client/src/app/retreat/steps/fifthStep.tsx index b47b7e4..1f60dc1 100644 --- a/client/src/app/retreat/steps/fifthStep.tsx +++ b/client/src/app/retreat/steps/fifthStep.tsx @@ -3,12 +3,11 @@ import { Stack, Box } from "@mui/material" import useRetreat from "../hooks/useRetreat" import RetreatButton from "../components/Button" -import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" export default function FifthStep() { const { isWorker, isHalf } = useRetreat() - const setNotificationMessage = useSetAtom(NotificationMessage) + const { success } = useNotification() function calculateRetreatFee() { if (isHalf) { @@ -54,9 +53,9 @@ export default function FifthStep() { label={"계좌 복사하기"} onClick={() => { navigator.clipboard.writeText( - "3333342703455 카카오뱅크 성은비 " + calculateRetreatFee() + "3333342703455 카카오뱅크 성은비 " + calculateRetreatFee(), ) - setNotificationMessage("계좌 정보가 복사되었습니다.") + success("계좌 정보가 복사되었습니다.") }} /> diff --git a/client/src/app/retreat/steps/fourth.tsx b/client/src/app/retreat/steps/fourth.tsx index 1a6d71c..820f68c 100644 --- a/client/src/app/retreat/steps/fourth.tsx +++ b/client/src/app/retreat/steps/fourth.tsx @@ -2,18 +2,16 @@ import { Stack, Box } from "@mui/material" import useRetreat from "../hooks/useRetreat" -import useAuth from "@/hooks/useAuth" import RetreatButton from "../components/Button" import { useEffect, useState } from "react" import { User } from "@server/entity/user" -import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" export default function FourthStep() { const [myInfo, setMyInfo] = useState(null) const { saveRetreatAttend, getMyInfo, isWorker, isHalf, setStep } = useRetreat() - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() useEffect(() => { getMyInfo().then((data) => { @@ -34,12 +32,12 @@ export default function FourthStep() { async function saveRetreatAttendWrapper() { try { const { data } = await saveRetreatAttend() - setNotificationMessage(data.result) + success(data.result) setStep(5) } catch (e) { - setNotificationMessage( + error( "참가 신청에 실패했습니다. 새로고침 후 다시 시도해주세요." + - e.toString() + e.toString(), ) } } diff --git a/client/src/components/form/UserInformationForm.tsx b/client/src/components/form/UserInformationForm.tsx index 685569c..1161a60 100644 --- a/client/src/components/form/UserInformationForm.tsx +++ b/client/src/components/form/UserInformationForm.tsx @@ -2,10 +2,8 @@ import { post } from "@/config/api" import InOutFrom from "./InOutForm" -import { useSetAtom } from "jotai" -import { useRouter } from "next/navigation" import { HowToMove } from "@server/entity/types" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import { Dispatch, SetStateAction, useEffect, useState } from "react" @@ -18,12 +16,11 @@ interface IProps { } export default function UserInformationForm(props: IProps) { - const router = useRouter() const [retreatAttend, setRetreatAttend] = useState( - undefined + undefined, ) const [inOutData, setInOutData] = useState>([]) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() useEffect(() => { setRetreatAttend(props.retreatAttend) @@ -39,11 +36,11 @@ export default function UserInformationForm(props: IProps) { const submit = async () => { if (!retreatAttend) { - setNotificationMessage("수련회 접수 정보 없음.") + error("수련회 접수 정보 없음.") return } if (!retreatAttend.howToGo) { - setNotificationMessage("이동 방법을 선택해주세요.") + error("이동 방법을 선택해주세요.") return } @@ -59,19 +56,17 @@ export default function UserInformationForm(props: IProps) { } if (saveResult.result !== "success") { - setNotificationMessage( - "접수중 오류가 발생하였습니다.\n다시 시도해주세요." - ) + error("접수중 오류가 발생하였습니다.\n다시 시도해주세요.") return } if (attendTimeResult && attendTimeResult.result !== "success") { - setNotificationMessage( - "이동 정보 저장중에 문제가 발생하였습니다.\n시간, 장소. 이동 방법을 모두 입력해주세요." + error( + "이동 정보 저장중에 문제가 발생하였습니다.\n시간, 장소. 이동 방법을 모두 입력해주세요.", ) return } - setNotificationMessage(`신청 내역이 저장이 되었습니다.`) + success(`신청 내역이 저장이 되었습니다.`) props.reloadFunction() props.setEditMode(false) } diff --git a/client/src/components/notification/notification.tsx b/client/src/components/notification/notification.tsx index fd3cdb7..b88961a 100644 --- a/client/src/components/notification/notification.tsx +++ b/client/src/components/notification/notification.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from "react" +import { useEffect, useState, useCallback, useRef } from "react" import { useAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" +import { notificationAtom, NotificationType } from "@/state/notification" import { Alert, Snackbar, @@ -9,13 +9,14 @@ import { Fade, Box, Typography, + Button, } from "@mui/material" import { TransitionProps } from "@mui/material/transitions" import React from "react" // Slide transition component for smoother animations function SlideTransition( - props: TransitionProps & { children: React.ReactElement } + props: TransitionProps & { children: React.ReactElement }, ) { return } @@ -24,59 +25,107 @@ interface NotificationItem { id: number content: string isVisible: boolean - severity?: "success" | "info" | "warning" | "error" + type: NotificationType + duration: number + action?: { + label: string + onClick: () => void + } } export default function Notification() { - const [notificationMessage, setNotificationMessage] = - useAtom(NotificationMessage) + const [notification, setNotification] = useAtom(notificationAtom) const [notifications, setNotifications] = useState([]) const [nextId, setNextId] = useState(1) + const timerRefs = useRef>(new Map()) // Add new notification when message changes useEffect(() => { - if (notificationMessage.trim().length > 0) { + if (notification) { const newNotification: NotificationItem = { id: nextId, - content: notificationMessage, + content: notification.message, isVisible: true, - severity: "success", + type: notification.type || "success", + duration: notification.duration || 4000, + action: notification.action, } setNotifications((prev) => [...prev, newNotification]) setNextId(nextId + 1) - setNotificationMessage("") + setNotification(null) - // Auto remove after 4 seconds - setTimeout(() => { + // Auto remove after duration + const timer = setTimeout(() => { setNotifications((prev) => prev.map((notif) => notif.id === newNotification.id ? { ...notif, isVisible: false } - : notif - ) + : notif, + ), ) // Remove from array after fade out animation setTimeout(() => { setNotifications((prev) => - prev.filter((notif) => notif.id !== newNotification.id) + prev.filter((notif) => notif.id !== newNotification.id), ) }, 300) - }, 4000) + }, newNotification.duration) + + timerRefs.current.set(newNotification.id, timer) + } + }, [notification, nextId, setNotification]) + + // Cleanup timers on unmount + useEffect(() => { + return () => { + timerRefs.current.forEach((timer) => clearTimeout(timer)) + timerRefs.current.clear() } - }, [notificationMessage, nextId]) + }, []) - const handleClose = (id: number) => { + const handleClose = useCallback((id: number) => { setNotifications((prev) => prev.map((notif) => - notif.id === id ? { ...notif, isVisible: false } : notif - ) + notif.id === id ? { ...notif, isVisible: false } : notif, + ), ) + const timer = timerRefs.current.get(id) + if (timer) { + clearTimeout(timer) + timerRefs.current.delete(id) + } + setTimeout(() => { setNotifications((prev) => prev.filter((notif) => notif.id !== id)) }, 300) + }, []) + + const getSeverityColor = (type: NotificationType) => { + switch (type) { + case "success": + return { + background: "linear-gradient(135deg, #4caf50 0%, #45a049 100%)", + color: "white", + } + case "error": + return { + background: "linear-gradient(135deg, #f44336 0%, #d32f2f 100%)", + color: "white", + } + case "warning": + return { + background: "linear-gradient(135deg, #ff9800 0%, #f57c00 100%)", + color: "white", + } + case "info": + return { + background: "linear-gradient(135deg, #2196f3 0%, #1976d2 100%)", + color: "white", + } + } } return ( @@ -91,43 +140,63 @@ export default function Notification() { }} > - {notifications.map((notification) => ( - - - handleClose(notification.id)} - sx={{ - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - borderRadius: 2, - "& .MuiAlert-message": { - fontSize: "0.95rem", - fontWeight: 500, - }, - "& .MuiAlert-action": { - padding: 0, - }, - background: - notification.severity === "success" - ? "linear-gradient(135deg, #4caf50 0%, #45a049 100%)" - : undefined, - color: - notification.severity === "success" ? "white" : undefined, - "& .MuiAlert-icon, & .MuiIconButton-root": { - color: - notification.severity === "success" ? "white" : undefined, - fontSize: - notification.severity === "success" ? "1.2rem" : "1.2rem", - }, - }} - > - - {notification.content} - - - - - ))} + {notifications.map((notification) => { + const colors = getSeverityColor(notification.type) + return ( + + + handleClose(notification.id)} + action={ + notification.action ? ( + + ) : undefined + } + sx={{ + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + borderRadius: 2, + "& .MuiAlert-message": { + fontSize: "0.95rem", + fontWeight: 500, + }, + "& .MuiAlert-action": { + padding: 0, + }, + background: colors.background, + color: colors.color, + "& .MuiAlert-icon, & .MuiIconButton-root": { + color: colors.color, + fontSize: "1.2rem", + }, + }} + > + + {notification.content} + + + + + ) + })} ) diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts index 6ba776d..bb2d41f 100644 --- a/client/src/hooks/useAuth.ts +++ b/client/src/hooks/useAuth.ts @@ -2,13 +2,13 @@ import { jwtDecode } from "jwt-decode" import { useEffect } from "react" -import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import { atom, useAtom, useAtomValue } from "jotai" import dayjs from "dayjs" import { Role } from "@server/util/type" import axios from "@/config/axios" import { Community } from "@server/entity/community" import { useRouter } from "next/navigation" -import { NotificationMessage } from "@/state/notification" +import { useNotification } from "@/hooks/useNotification" export const JwtInformationAtom = atom(null) @@ -28,7 +28,7 @@ const kakaoTokenAtom = atom(null) export default function useAuth() { const { push } = useRouter() const isLogin = useAtomValue(isLoginAtom) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() const [authUserData, setAuthUserData] = useAtom(JwtInformationAtom) const [kakaoToken, setKakaoToken] = useAtom(kakaoTokenAtom) @@ -110,7 +110,7 @@ export default function useAuth() { if (authUserData.role.Leader === false) { push("/") - setNotificationMessage("순장 권한이 없습니다.") + error("순장 권한이 없습니다.") return false } return true @@ -125,7 +125,7 @@ export default function useAuth() { if (authUserData.role.Admin === false) { push("/") - setNotificationMessage("관리자 권한이 없습니다.") + error("관리자 권한이 없습니다.") return false } return true diff --git a/client/src/hooks/useNotification.ts b/client/src/hooks/useNotification.ts new file mode 100644 index 0000000..8aebe33 --- /dev/null +++ b/client/src/hooks/useNotification.ts @@ -0,0 +1,61 @@ +import { useSetAtom } from "jotai" +import { notificationAtom, NotificationOptions } from "@/state/notification" + +export function useNotification() { + const setNotification = useSetAtom(notificationAtom) + + return { + notify: (message: string, options?: Partial) => { + setNotification({ + message, + type: options?.type || "success", + duration: options?.duration || 4000, + action: options?.action, + }) + }, + success: ( + message: string, + options?: Omit, + ) => { + setNotification({ + message, + type: "success", + duration: options?.duration || 4000, + action: options?.action, + }) + }, + error: ( + message: string, + options?: Omit, + ) => { + setNotification({ + message, + type: "error", + duration: options?.duration || 4000, + action: options?.action, + }) + }, + warning: ( + message: string, + options?: Omit, + ) => { + setNotification({ + message, + type: "warning", + duration: options?.duration || 4000, + action: options?.action, + }) + }, + info: ( + message: string, + options?: Omit, + ) => { + setNotification({ + message, + type: "info", + duration: options?.duration || 4000, + action: options?.action, + }) + }, + } +} diff --git a/client/src/state/notification.ts b/client/src/state/notification.ts index ec2fa1c..ee8cb53 100644 --- a/client/src/state/notification.ts +++ b/client/src/state/notification.ts @@ -1,3 +1,15 @@ import { atom } from "jotai" -export const NotificationMessage = atom("") +export type NotificationType = "success" | "error" | "warning" | "info" + +export interface NotificationOptions { + message: string + type?: NotificationType + duration?: number + action?: { + label: string + onClick: () => void + } +} + +export const notificationAtom = atom(null) From deff25091cce0967c276e36782f34b60eb6d8e54 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 9 Feb 2026 21:50:26 +0900 Subject: [PATCH 2/5] feat: notification update --- .../src/app/retreat/admin/all-user/page.tsx | 10 +++++----- .../retreat/admin/carpooling-list/page.tsx | 14 ++++++------- .../app/retreat/admin/deposit-check/page.tsx | 8 ++++---- .../app/retreat/admin/edit-user-data/page.tsx | 16 +++++++-------- .../retreat/admin/group-formation/page.tsx | 14 ++++++------- .../src/app/retreat/admin/inout-info/page.tsx | 14 ++++++------- .../retreat/admin/permission-manage/page.tsx | 10 +++++----- .../retreat/admin/room-assignment/page.tsx | 20 +++++++++---------- .../retreat/admin/show-status-table/page.tsx | 18 ++++++++--------- 9 files changed, 62 insertions(+), 62 deletions(-) diff --git a/client/src/app/retreat/admin/all-user/page.tsx b/client/src/app/retreat/admin/all-user/page.tsx index 6faa08f..055a26a 100644 --- a/client/src/app/retreat/admin/all-user/page.tsx +++ b/client/src/app/retreat/admin/all-user/page.tsx @@ -10,18 +10,18 @@ import { TableRow, } from "@mui/material" import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" import { useRouter } from "next/navigation" import { HowToMove } from "@server/entity/types" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import { get } from "@/config/api" import Header from "@/components/retreat/admin/Header" +import { useNotification } from "@/hooks/useNotification" function AllUser() { const router = useRouter() const [allUserList, setAllUserList] = useState>([]) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() useEffect(() => { ;(async () => { @@ -29,12 +29,12 @@ function AllUser() { const list: RetreatAttend[] = await get("/retreat/admin/get-all-user") if (list) { setAllUserList( - list.sort((a, b) => a.attendanceNumber - b.attendanceNumber) + list.sort((a, b) => a.attendanceNumber - b.attendanceNumber), ) } } catch { router.push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return } })() @@ -42,7 +42,7 @@ function AllUser() { function downloadFile() { if (allUserList.length === 0) { - setNotificationMessage("접수자가 없습니다!.") + error("접수자가 없습니다!.") return } diff --git a/client/src/app/retreat/admin/carpooling-list/page.tsx b/client/src/app/retreat/admin/carpooling-list/page.tsx index d8cd1c4..7975614 100644 --- a/client/src/app/retreat/admin/carpooling-list/page.tsx +++ b/client/src/app/retreat/admin/carpooling-list/page.tsx @@ -14,24 +14,24 @@ import { } from "@mui/material" import { get } from "@/config/api" import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" import { useRouter } from "next/navigation" import { Days, HowToMove } from "@server/entity/types" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import Header from "@/components/retreat/admin/Header" +import { useNotification } from "@/hooks/useNotification" let allInoutInfoList: InOutInfo[] = [] export default function CarpoolingList() { const router = useRouter() const [moveTypeFilter, setMoveTypeFilter] = useState( - HowToMove.none + HowToMove.none, ) const [matchFilter, setMatchFilter] = useState< "true" | "false" | "undefined" >("undefined") const [inOutInfoList, setAllUserList] = useState>([]) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { ;(async () => { @@ -43,7 +43,7 @@ export default function CarpoolingList() { } } catch { router.push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return } })() @@ -54,18 +54,18 @@ export default function CarpoolingList() { if (matchFilter === "true") { result = result.filter( (inOutInfo) => - inOutInfo.userInTheCar.length > 0 || !!inOutInfo.rideCarInfo + inOutInfo.userInTheCar.length > 0 || !!inOutInfo.rideCarInfo, ) } else if (matchFilter === "false") { result = result.filter( (inOutInfo) => - inOutInfo.userInTheCar.length === 0 && !inOutInfo.rideCarInfo + inOutInfo.userInTheCar.length === 0 && !inOutInfo.rideCarInfo, ) } if (moveTypeFilter !== HowToMove.none) { result = result.filter( - (inOutInfo) => inOutInfo.howToMove === moveTypeFilter + (inOutInfo) => inOutInfo.howToMove === moveTypeFilter, ) } diff --git a/client/src/app/retreat/admin/deposit-check/page.tsx b/client/src/app/retreat/admin/deposit-check/page.tsx index 487f9a0..b15b049 100644 --- a/client/src/app/retreat/admin/deposit-check/page.tsx +++ b/client/src/app/retreat/admin/deposit-check/page.tsx @@ -12,11 +12,11 @@ import { import { get, post } from "@/config/api" import { useEffect, useState } from "react" import { useRouter } from "next/navigation" -import { NotificationMessage } from "@/state/notification" import { useSetAtom } from "jotai" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import { Deposit } from "@server/entity/types" import Header from "@/components/retreat/admin/Header" +import { useNotification } from "@/hooks/useNotification" function DepositCheck() { const { push } = useRouter() @@ -25,7 +25,7 @@ function DepositCheck() { const [isShowUnpaid, setIsShowUnpaid] = useState(false) const [depositSum, setDepositSum] = useState(0) const [allUserList, setAllUserList] = useState([] as Array) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { fetchData() @@ -41,7 +41,7 @@ function DepositCheck() { }) if (result.result === "error") { - alert("오류 발생!") + error("오류 발생!") return } @@ -89,7 +89,7 @@ function DepositCheck() { }) .catch(() => { push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return }) } diff --git a/client/src/app/retreat/admin/edit-user-data/page.tsx b/client/src/app/retreat/admin/edit-user-data/page.tsx index 5b6ef2a..34c6e4d 100644 --- a/client/src/app/retreat/admin/edit-user-data/page.tsx +++ b/client/src/app/retreat/admin/edit-user-data/page.tsx @@ -12,29 +12,29 @@ import { } from "@mui/material" import UserInformationForm from "@/components/form/UserInformationForm" import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" import { useRouter } from "next/navigation" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import Header from "@/components/retreat/admin/Header" +import { useNotification } from "@/hooks/useNotification" export default function EditUserData() { const { push } = useRouter() const [userList, setUserList] = useState([] as Array) const [selectedUserId, setSelectedUserId] = useState("") const [retreatAttend, setRetreatAttend] = useState( - undefined + undefined, ) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error, success } = useNotification() useEffect(() => { get("/retreat/admin/get-all-user") .then((response: Array) => { setUserList( - response.sort((a, b) => (a.user.name > b.user.name ? 1 : -1)) + response.sort((a, b) => (a.user.name > b.user.name ? 1 : -1)), ) if (global.location.search) { const retreatAttendId = new URLSearchParams( - global.location.search + global.location.search, ).get("retreatAttendId") setSelectedUserId(retreatAttendId ? retreatAttendId : "") } else if (response.length > 0) { @@ -43,7 +43,7 @@ export default function EditUserData() { }) .catch(() => { push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return }) }, []) @@ -71,7 +71,7 @@ export default function EditUserData() { `${ userList.find((retreatAttend) => retreatAttend.id === selectedUserId) ?.user.name - }의 정보를 삭제하시겠습니까?` + }의 정보를 삭제하시겠습니까?`, ) if (!c) { return @@ -81,7 +81,7 @@ export default function EditUserData() { }) if (result === "success") { - setNotificationMessage("삭제되었습니다.") + success("삭제되었습니다.") } } diff --git a/client/src/app/retreat/admin/group-formation/page.tsx b/client/src/app/retreat/admin/group-formation/page.tsx index 82a18eb..a4b4fef 100644 --- a/client/src/app/retreat/admin/group-formation/page.tsx +++ b/client/src/app/retreat/admin/group-formation/page.tsx @@ -4,16 +4,16 @@ import { Box, Stack } from "@mui/material" import { useEffect, useState } from "react" import { get, post } from "@/config/api" import { useRouter } from "next/navigation" -import { NotificationMessage } from "@/state/notification" import { useSetAtom } from "jotai" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import Header from "@/components/retreat/admin/Header" +import { useNotification } from "@/hooks/useNotification" function GroupFormation() { const { push } = useRouter() const [unassignedUserList, setUnassignedUserList] = useState( - [] as Array + [] as Array, ) const [groupList, setGroupList] = useState([] as Array>) const [selectedUser, setSelectedUser] = useState() @@ -24,7 +24,7 @@ function GroupFormation() { const [showUserInfo, setShowUserInfo] = useState({} as RetreatAttend) const [userAttendInfo, setUserAttendInfo] = useState([] as Array) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() function onMouseMove(event: MouseEvent) { setMousePoint([event.pageX, event.pageY]) @@ -49,7 +49,7 @@ function GroupFormation() { const group = [] as Array> const assignedUserList = response.filter( - (retreatAttend) => retreatAttend.groupNumber !== 0 + (retreatAttend) => retreatAttend.groupNumber !== 0, ) assignedUserList.map((retreatAttend) => { const groupNumber = retreatAttend.groupNumber - 1 @@ -61,13 +61,13 @@ function GroupFormation() { setGroupList(group) }) const maxNumber = Math.max( - ...response.map((retreatAttend) => retreatAttend.groupNumber) + ...response.map((retreatAttend) => retreatAttend.groupNumber), ) setMaxGroupNumber(maxNumber) }) .catch(() => { push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return }) } @@ -257,7 +257,7 @@ function GroupFormation() { } else { return Group( group[0].groupNumber, - group.sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth) + group.sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth), ) } })} diff --git a/client/src/app/retreat/admin/inout-info/page.tsx b/client/src/app/retreat/admin/inout-info/page.tsx index 724c0ed..b3603a3 100644 --- a/client/src/app/retreat/admin/inout-info/page.tsx +++ b/client/src/app/retreat/admin/inout-info/page.tsx @@ -4,17 +4,17 @@ import { useEffect, useState } from "react" import { Box, Stack } from "@mui/material" import { useRouter } from "next/navigation" import { get } from "@/config/api" -import { NotificationMessage } from "@/state/notification" import { useSetAtom } from "jotai" import { Days, HowToMove, InOutType } from "@server/entity/types" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" +import { useNotification } from "@/hooks/useNotification" function InoutInfo() { const router = useRouter() const [allUserList, setAllUserList] = useState>([]) const [allInoutInfo, setAllInoutInfo] = useState([] as InOutInfo[]) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { ;(async () => { @@ -25,7 +25,7 @@ function InoutInfo() { } } catch { router.push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return } get("/retreat/admin/get-car-info") @@ -34,7 +34,7 @@ function InoutInfo() { }) .catch(() => { router.push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return }) })() @@ -70,7 +70,7 @@ function InoutInfo() { .sort( (a, b) => a.retreatAttend.user.yearOfBirth - - b.retreatAttend.user.yearOfBirth + b.retreatAttend.user.yearOfBirth, ) .map((info) => ( a.retreatAttend.user.yearOfBirth - - b.retreatAttend.user.yearOfBirth + b.retreatAttend.user.yearOfBirth, ) .map((info) => ( a.retreatAttend.user.yearOfBirth - - b.retreatAttend.user.yearOfBirth + b.retreatAttend.user.yearOfBirth, ) .map((info) => ( ({}) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { get("/retreat/admin/get-all-user-name") @@ -38,7 +38,7 @@ function PermissionManage() { }) .catch(() => { push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return }) }, []) @@ -73,7 +73,7 @@ function PermissionManage() { const data: { [key: number]: boolean } = {} response.map( (permission: { permissionType: number; have: boolean }) => - (data[permission.permissionType] = permission.have) + (data[permission.permissionType] = permission.have), ) setUserPermission(data) }) @@ -81,7 +81,7 @@ function PermissionManage() { function onChangePermission( event: ChangeEvent, - key: number + key: number, ) { post("/retreat/admin/set-user-permission", { userId: selectedUserId, diff --git a/client/src/app/retreat/admin/room-assignment/page.tsx b/client/src/app/retreat/admin/room-assignment/page.tsx index babcac5..2857518 100644 --- a/client/src/app/retreat/admin/room-assignment/page.tsx +++ b/client/src/app/retreat/admin/room-assignment/page.tsx @@ -5,15 +5,15 @@ import { useEffect, useState } from "react" import { get, post } from "../../../../config/api" import { useRouter } from "next/navigation" import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import Header from "@/components/retreat/admin/Header" +import { useNotification } from "@/hooks/useNotification" function RoomAssingment() { const { push } = useRouter() const [unassignedUserList, setUnassignedUserList] = useState( - [] as Array + [] as Array, ) const [roomList, setRoomList] = useState([] as Array>) const [selectedUser, setSelectedUser] = useState() @@ -26,7 +26,7 @@ function RoomAssingment() { const [shiftPosition, setShiftPosition] = useState({ x: 0, y: 0 }) const [gender, setGender] = useState("man") - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() function onMouseMove(event: MouseEvent) { setMousePoint([event.pageX, event.pageY]) @@ -47,14 +47,14 @@ function RoomAssingment() { const unassignedUserList = response .filter( (retreatAttend) => - !retreatAttend.roomNumber || retreatAttend.roomNumber === 0 + !retreatAttend.roomNumber || retreatAttend.roomNumber === 0, ) .sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth) setUnassignedUserList(unassignedUserList) const room = [] as Array> const assignedUserList = response.filter( - (retreatAttend) => retreatAttend.roomNumber + (retreatAttend) => retreatAttend.roomNumber, ) assignedUserList.map((retreatAttend) => { const roomNumber = retreatAttend.roomNumber - 1 @@ -63,19 +63,19 @@ function RoomAssingment() { } else { room[roomNumber].push(retreatAttend) room[roomNumber].sort( - (a, b) => a.user.yearOfBirth - b.user.yearOfBirth + (a, b) => a.user.yearOfBirth - b.user.yearOfBirth, ) } setRoomList(room) }) const maxNumber = Math.max( - ...response.map((retreatAttend) => retreatAttend.roomNumber) + ...response.map((retreatAttend) => retreatAttend.roomNumber), ) setMaxRoomNumber(maxNumber) }) .catch(() => { push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return }) } @@ -213,7 +213,7 @@ function RoomAssingment() { {roomNumber}번 방 ( { userList.filter( - (retreatAttend) => retreatAttend.user.gender === gender + (retreatAttend) => retreatAttend.user.gender === gender, ).length } ) @@ -299,7 +299,7 @@ function RoomAssingment() { 미배정( { unassignedUserList.filter( - (roomNumber) => roomNumber.user.gender === gender + (roomNumber) => roomNumber.user.gender === gender, ).length } 명) diff --git a/client/src/app/retreat/admin/show-status-table/page.tsx b/client/src/app/retreat/admin/show-status-table/page.tsx index 528623c..c6c8fad 100644 --- a/client/src/app/retreat/admin/show-status-table/page.tsx +++ b/client/src/app/retreat/admin/show-status-table/page.tsx @@ -6,14 +6,14 @@ import { useRouter } from "next/navigation" import { get } from "@/config/api" import { useEffect, useState } from "react" import { useSetAtom } from "jotai" -import { NotificationMessage } from "@/state/notification" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" +import { useNotification } from "@/hooks/useNotification" export default function ShowStatusTable() { const router = useRouter() const [statusFilter, setStatusFilter] = useState(CurrentStatus.arriveChurch) const [allUserList, setAllUserList] = useState>([]) - const setNotificationMessage = useSetAtom(NotificationMessage) + const { error } = useNotification() useEffect(() => { fetchUserData() @@ -25,25 +25,25 @@ export default function ShowStatusTable() { const list: RetreatAttend[] = await get("/retreat/admin/get-all-user") if (list) { setAllUserList( - list.sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth) + list.sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth), ) } } catch { router.push("/retreat/admin") - setNotificationMessage("권한이 없습니다.") + error("권한이 없습니다.") return } } function ArriveCurchUserList() { const busUser = allUserList.filter( - (user) => user.howToGo === HowToMove.together + (user) => user.howToGo === HowToMove.together, ) const arriveUser = busUser.filter( - (user) => user.currentStatus === CurrentStatus.arriveChurch + (user) => user.currentStatus === CurrentStatus.arriveChurch, ) const notArriveUser = busUser.filter( - (user) => user.currentStatus === CurrentStatus.null + (user) => user.currentStatus === CurrentStatus.null, ) return ( @@ -78,11 +78,11 @@ export default function ShowStatusTable() { function ArriveAuditoriumUserList() { const arriveUser = allUserList.filter( - (user) => user.currentStatus === CurrentStatus.arriveAuditorium + (user) => user.currentStatus === CurrentStatus.arriveAuditorium, ) const notArriveUser = allUserList.filter( - (user) => user.currentStatus !== CurrentStatus.arriveAuditorium + (user) => user.currentStatus !== CurrentStatus.arriveAuditorium, ) return ( From a555d3c40b2337fbe9ebd891986535cbf75771bd Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 9 Feb 2026 22:22:56 +0900 Subject: [PATCH 3/5] =?UTF-8?q?update:=20=EC=88=98=EB=A0=A8=ED=9A=8C=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=9D=BC=EB=B6=80=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/retreat/admin/all-user/page.tsx | 273 ++++++++++++++---- .../app/retreat/admin/dash-board/StatCard.tsx | 68 ++++- .../src/app/retreat/admin/dash-board/page.tsx | 187 +----------- client/src/app/retreat/admin/page.tsx | 267 ++++++++++++++++- .../components/retreat/admin/Header/index.tsx | 52 ++-- 5 files changed, 566 insertions(+), 281 deletions(-) diff --git a/client/src/app/retreat/admin/all-user/page.tsx b/client/src/app/retreat/admin/all-user/page.tsx index 055a26a..49f7e05 100644 --- a/client/src/app/retreat/admin/all-user/page.tsx +++ b/client/src/app/retreat/admin/all-user/page.tsx @@ -8,8 +8,11 @@ import { TableCell, TableHead, TableRow, + Paper, + Box, + Button, + Typography, } from "@mui/material" -import { useSetAtom } from "jotai" import { useRouter } from "next/navigation" import { HowToMove } from "@server/entity/types" import { InOutInfo } from "@server/entity/retreat/inOutInfo" @@ -17,6 +20,7 @@ import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import { get } from "@/config/api" import Header from "@/components/retreat/admin/Header" import { useNotification } from "@/hooks/useNotification" +import FileDownloadIcon from "@mui/icons-material/FileDownload" function AllUser() { const router = useRouter() @@ -66,71 +70,228 @@ function AllUser() { hiddenElement.download = "출입 정보.txt" hiddenElement.click() }) + + success("다운로드가 완료되었습니다.") } return ( - +
- + - 전체 접수자 목록 + + 전체 접수자 목록 + + + - {/* */} - - - - - 접수 번호 - 이름 - 성별 - 나이 - 가는 방법 - 오는 방법 - - - - {allUserList.map((retreatAttend) => ( - - {retreatAttend.attendanceNumber} - {retreatAttend.user.name} - - {retreatAttend.user.gender === "man" ? "남" : "여"} - - {retreatAttend.user.yearOfBirth} - - {retreatAttend.howToGo === HowToMove.together - ? "교회 버스" - : "기타"} - - - {retreatAttend.howToBack === HowToMove.together - ? "교회 버스" - : "기타"} - - - ))} - -
- + + + + + + 접수 번호 + + + 이름 + + + 성별 + + + 나이 + + + 가는 방법 + + + 오는 방법 + + + + + {allUserList.map((retreatAttend, index) => ( + + + {retreatAttend.attendanceNumber} + + + {retreatAttend.user.name} + + + {retreatAttend.user.gender === "man" ? "남" : "여"} + + + {retreatAttend.user.yearOfBirth} + + + {retreatAttend.howToGo === HowToMove.together + ? "교회 버스" + : "기타"} + + + {retreatAttend.howToBack === HowToMove.together + ? "교회 버스" + : "기타"} + + + ))} + +
+
+ + + ) } diff --git a/client/src/app/retreat/admin/dash-board/StatCard.tsx b/client/src/app/retreat/admin/dash-board/StatCard.tsx index 0cd454b..b7b0846 100644 --- a/client/src/app/retreat/admin/dash-board/StatCard.tsx +++ b/client/src/app/retreat/admin/dash-board/StatCard.tsx @@ -13,37 +13,79 @@ export default function StatCard({ title, value, subtitle, - color = "#333", + color = "#0a0a0a", backgroundColor = "#fff", icon, }: StatCardProps) { return ( - - {icon && {icon}} - - + + {icon && ( + + {icon} + + )} + + {title} - + {value} {subtitle && ( - + {subtitle} )} diff --git a/client/src/app/retreat/admin/dash-board/page.tsx b/client/src/app/retreat/admin/dash-board/page.tsx index dda6b92..735e8ab 100644 --- a/client/src/app/retreat/admin/dash-board/page.tsx +++ b/client/src/app/retreat/admin/dash-board/page.tsx @@ -1,187 +1,14 @@ "use client" -import { Box, Grid, Typography } from "@mui/material" -import { get } from "../../../../config/api" -import { useEffect, useState } from "react" -import { InOutInfo } from "@server/entity/retreat/inOutInfo" -import Header from "@/components/retreat/admin/Header" -import StatCard from "@/app/retreat/admin/dash-board/StatCard" -import BarChart from "@/app/retreat/admin/dash-board/BarChart" -import AttendanceTimeline from "@/app/retreat/admin/dash-board/AttendanceTimeline" -import PeopleIcon from "@mui/icons-material/People" -import TrendingUpIcon from "@mui/icons-material/TrendingUp" -import MaleIcon from "@mui/icons-material/Male" -import FemaleIcon from "@mui/icons-material/Female" -import DirectionsBusIcon from "@mui/icons-material/DirectionsBus" -import PaymentIcon from "@mui/icons-material/Payment" +import { useRouter } from "next/navigation" +import { useEffect } from "react" -function DashBoard() { - const [attendeeStatus, setAttendeeStatus] = useState( - {} as Record - ) - const [attendanceTimeList, setAttendanceTimeList] = useState([]) - const [ageInfoList, setAgeInfoList] = useState>({}) - const [infoList, setInfoList] = useState([]) +export default function DashBoard() { + const router = useRouter() useEffect(() => { - fetchData() - const interval = setInterval(fetchData, 1000 * 60 * 30) - return () => clearInterval(interval) - }, []) + router.push("/retreat/admin") + }, [router]) - async function fetchData() { - try { - const [status, timeList, ageInfo, info] = await Promise.all([ - get("/retreat/admin/get-attendee-status"), - get("/retreat/admin/get-attendance-time"), - get("/retreat/admin/get-age-info"), - get("/retreat/admin/in-out-info"), - ]) - - setAttendeeStatus(status) - setAttendanceTimeList(timeList) - setAgeInfoList(ageInfo) - setInfoList(info) - } catch (error) { - console.error("데이터 조회 실패:", error) - } - } - - // 일별 등록자 데이터 처리 - 날짜순으로 정렬 - const getDailyRegistrationData = () => { - const timeData: Record = {} - attendanceTimeList.forEach((time) => { - const date = new Date(time) - const key = `${date.getMonth() + 1}.${date.getDate()}` - timeData[key] = (timeData[key] || 0) + 1 - }) - - // 날짜순으로 정렬 - const sortedEntries = Object.entries(timeData).sort(([a], [b]) => { - const [monthA, dayA] = a.split(".").map(Number) - const [monthB, dayB] = b.split(".").map(Number) - - if (monthA !== monthB) { - return monthA - monthB - } - return dayA - dayB - }) - - return Object.fromEntries(sortedEntries) - } - - const targetCount = 400 - const attendanceRate = ((attendeeStatus.all / targetCount) * 100).toFixed(1) - const maleRate = ((attendeeStatus.man / attendeeStatus.all) * 100).toFixed(1) - const femaleRate = ( - (attendeeStatus.woman / attendeeStatus.all) * - 100 - ).toFixed(1) - const depositRate = ( - (attendeeStatus.completeDeposit / attendeeStatus.all) * - 100 - ).toFixed(1) - - return ( - -
- - - - 수련회 현황 대시보드 - - - {/* 주요 통계 카드들 */} - - } - backgroundColor="#E3F2FD" - /> - - } - backgroundColor="#E8F5E8" - /> - - - - - - } - backgroundColor="#FFF3E0" - /> - - } - backgroundColor="#F3E5F5" - /> - - - {/* 버스 이용 현황 */} - - } - backgroundColor="#E1F5FE" - /> - - } - backgroundColor="#E8EAF6" - /> - - } - backgroundColor="#FDE7E7" - /> - - - {/* 차트들 */} - - - - [ - key.slice(2, 4) + "년생", - value, - ]) - )} - color="#10B981" - /> - - - {/* 시간별 참석자 현황 - - */} - - - ) + return null } - -export default DashBoard diff --git a/client/src/app/retreat/admin/page.tsx b/client/src/app/retreat/admin/page.tsx index 9aa1c0d..d3eeb9c 100644 --- a/client/src/app/retreat/admin/page.tsx +++ b/client/src/app/retreat/admin/page.tsx @@ -1,26 +1,277 @@ "use client" -import jwt from "jwt-decode" -import useKakaoHook from "@/hooks/useKakao" -import { post } from "@/config/api" -import { useRouter } from "next/navigation" +import { Box, Stack, Typography } from "@mui/material" +import { get } from "../../../config/api" import { useEffect, useState } from "react" -import { Button, Stack } from "@mui/material" +import { InOutInfo } from "@server/entity/retreat/inOutInfo" import Header from "@/components/retreat/admin/Header" +import StatCard from "@/app/retreat/admin/dash-board/StatCard" +import BarChart from "@/app/retreat/admin/dash-board/BarChart" +import PeopleIcon from "@mui/icons-material/People" +import TrendingUpIcon from "@mui/icons-material/TrendingUp" +import MaleIcon from "@mui/icons-material/Male" +import FemaleIcon from "@mui/icons-material/Female" +import DirectionsBusIcon from "@mui/icons-material/DirectionsBus" +import PaymentIcon from "@mui/icons-material/Payment" import useAuth from "@/hooks/useAuth" //아이콘 주소 https://www.flaticon.com/kr/ export default function Admin() { - const router = useRouter() const { ifNotLoggedGoToLogin } = useAuth() + const [attendeeStatus, setAttendeeStatus] = useState( + {} as Record, + ) + const [attendanceTimeList, setAttendanceTimeList] = useState([]) + const [ageInfoList, setAgeInfoList] = useState>({}) + const [infoList, setInfoList] = useState([]) useEffect(() => { ifNotLoggedGoToLogin("/retreat/admin") + fetchData() + const interval = setInterval(fetchData, 1000 * 60 * 30) + return () => clearInterval(interval) }, []) + async function fetchData() { + try { + const [status, timeList, ageInfo, info] = await Promise.all([ + get("/retreat/admin/get-attendee-status"), + get("/retreat/admin/get-attendance-time"), + get("/retreat/admin/get-age-info"), + get("/retreat/admin/in-out-info"), + ]) + + setAttendeeStatus(status) + setAttendanceTimeList(timeList) + setAgeInfoList(ageInfo) + setInfoList(info) + } catch (error) { + console.error("데이터 조회 실패:", error) + } + } + + // 일별 등록자 데이터 처리 - 날짜순으로 정렬 + const getDailyRegistrationData = () => { + const timeData: Record = {} + attendanceTimeList.forEach((time) => { + const date = new Date(time) + const key = `${date.getMonth() + 1}.${date.getDate()}` + timeData[key] = (timeData[key] || 0) + 1 + }) + + // 날짜순으로 정렬 + const sortedEntries = Object.entries(timeData).sort(([a], [b]) => { + const [monthA, dayA] = a.split(".").map(Number) + const [monthB, dayB] = b.split(".").map(Number) + + if (monthA !== monthB) { + return monthA - monthB + } + return dayA - dayB + }) + + return Object.fromEntries(sortedEntries) + } + + const targetCount = 400 + const attendanceRate = ((attendeeStatus.all / targetCount) * 100).toFixed(1) + const maleRate = ((attendeeStatus.man / attendeeStatus.all) * 100).toFixed(1) + const femaleRate = ( + (attendeeStatus.woman / attendeeStatus.all) * + 100 + ).toFixed(1) + const depositRate = ( + (attendeeStatus.completeDeposit / attendeeStatus.all) * + 100 + ).toFixed(1) + return ( - +
- + + + {/* 주요 통계 섹션 */} + + + + 주요 통계 + + + } + backgroundColor="#E3F2FD" + /> + + } + backgroundColor="#E8F5E8" + /> + + + + + + } + backgroundColor="#FFF3E0" + /> + + } + backgroundColor="#F3E5F5" + /> + + + + {/* 버스 이용 현황 섹션 */} + + + + 버스 이용 현황 + + + } + backgroundColor="#E1F5FE" + /> + + } + backgroundColor="#E8EAF6" + /> + + } + backgroundColor="#FDE7E7" + /> + + + + {/* 통계 분석 섹션 */} + + + + 통계 분석 + + + + [ + key.slice(2, 4) + "년생", + value, + ]), + )} + color="#10B981" + /> + + + + {/* 시간별 참석자 현황 + + */} + ) } diff --git a/client/src/components/retreat/admin/Header/index.tsx b/client/src/components/retreat/admin/Header/index.tsx index fc382fb..465bf0d 100644 --- a/client/src/components/retreat/admin/Header/index.tsx +++ b/client/src/components/retreat/admin/Header/index.tsx @@ -1,7 +1,6 @@ import { Box, Button, - Divider, Drawer, List, ListItem, @@ -12,11 +11,21 @@ import { } from "@mui/material" import { useEffect, useState } from "react" import MenuIcon from "@mui/icons-material/Menu" +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted" +import DirectionsCarIcon from "@mui/icons-material/DirectionsCar" +import ListIcon from "@mui/icons-material/List" +import BedtimeIcon from "@mui/icons-material/Bedtime" +import GroupsIcon from "@mui/icons-material/Groups" +import LockIcon from "@mui/icons-material/Lock" +import AttachMoneyIcon from "@mui/icons-material/AttachMoney" +import EditIcon from "@mui/icons-material/Edit" +import QrCode2Icon from "@mui/icons-material/QrCode2" +import TableChartIcon from "@mui/icons-material/TableChart" +import LoginIcon from "@mui/icons-material/Login" +import DashboardIcon from "@mui/icons-material/Dashboard" import { useRouter } from "next/navigation" -import PermIdentityOutlinedIcon from "@mui/icons-material/PermIdentityOutlined" import useUserData from "@/hooks/useUserData" -import Image from "next/image" export default function Header() { const { getUserDataFromToken } = useUserData() @@ -45,11 +54,11 @@ export default function Header() { function RouterRow({ pageURL, pageName, - icon, + icon: IconComponent, }: { pageURL: string pageName: string - icon: string + icon: React.ElementType }) { function goToPage() { push("/retreat/admin" + pageURL) @@ -58,7 +67,7 @@ export default function Header() { - + @@ -85,8 +94,8 @@ export default function Header() { + )} diff --git a/client/src/app/leader/attendance/useAttendance.ts b/client/src/app/leader/attendance/useAttendance.ts index bb787f1..4ab6593 100644 --- a/client/src/app/leader/attendance/useAttendance.ts +++ b/client/src/app/leader/attendance/useAttendance.ts @@ -7,6 +7,7 @@ import axios from "@/config/axios" import { useEffect } from "react" import { atom, useAtom } from "jotai" import { AttendStatus } from "@server/entity/types" +import { useNotification } from "@/hooks/useNotification" const groupInfoAtom = atom(undefined) const worshipScheduleListAtom = atom([]) @@ -16,12 +17,13 @@ const soonAttendDataAtom = atom([]) export default function useAttendance() { const [groupInfo, setGroupInfo] = useAtom(groupInfoAtom) const [worshipScheduleList, setWorshipScheduleList] = useAtom( - worshipScheduleListAtom + worshipScheduleListAtom, ) const [selectedScheduleId, setSelectedScheduleId] = useAtom( - selectedScheduleIdAtom + selectedScheduleIdAtom, ) const [soonAttendData, setSoonAttendData] = useAtom(soonAttendDataAtom) + const { success, error } = useNotification() useEffect(() => { fetchData() @@ -36,12 +38,12 @@ export default function useAttendance() { async function getAttendData(scheduleId: number) { const usersIds = groupInfo?.users.map((user) => user.id) || [] const { data } = await axios.get( - `/soon/attendance/?scheduleId=${scheduleId}` + `/soon/attendance/?scheduleId=${scheduleId}`, ) groupInfo?.users.forEach((user) => { if ( !data.find( - (d) => d.user.id === user.id && d.worshipSchedule.id === scheduleId + (d) => d.user.id === user.id && d.worshipSchedule.id === scheduleId, ) ) { data.push({ @@ -56,11 +58,11 @@ export default function useAttendance() { async function fetchData() { const { data: groupInfo } = await axios.get( - "/soon/my-group-info" + "/soon/my-group-info", ) setGroupInfo(groupInfo) const { data: worshipScheduleList } = await axios.get( - "/soon/worship-schedule" + "/soon/worship-schedule", ) setWorshipScheduleList(worshipScheduleList) if (worshipScheduleList.length > 0) { @@ -68,6 +70,73 @@ export default function useAttendance() { } } + function getLastSundayDateString(): string { + const today = new Date() + const dayOfWeek = today.getDay() + let daysToSubtract = 0 + + if (dayOfWeek === 0) { + // 오늘이 일요일 → 저번주 일요일 (7일 전) + daysToSubtract = 7 + } else { + // 월-토 → dayOfWeek + 7일 전 + daysToSubtract = dayOfWeek + 7 + } + + const lastSunday = new Date(today) + lastSunday.setDate(lastSunday.getDate() - daysToSubtract) + + // 로컬 시간 기준으로 YYYY-MM-DD 형식 정규화 + const year = lastSunday.getFullYear() + const month = String(lastSunday.getMonth() + 1).padStart(2, "0") + const date = String(lastSunday.getDate()).padStart(2, "0") + + return `${year}-${month}-${date}` + } + + async function loadLastSundayAttendance() { + if (!groupInfo) return + + const dateStr = getLastSundayDateString() + console.log("지난주 일요일 날짜:", dateStr) + + // 지난주 일요일 일정 찾기 + const lastSundaySchedule = worshipScheduleList.find( + (schedule) => schedule.date === dateStr, + ) + + console.log("지난주 일요일 일정:", lastSundaySchedule?.id) + if (!lastSundaySchedule) { + error("지난주 일요일 예배 일정이 없습니다.") + return + } + + // 지난주 일요일 출석 데이터 조회 + const { data: lastSundayAttendData } = await axios.get( + `/soon/attendance/?scheduleId=${lastSundaySchedule.id}`, + ) + + // 현재 출석 데이터에 지난주 상태 적용 (저장 상태는 유지) + const updatedAttendData = soonAttendData.map((attendData) => { + const lastSundayData = lastSundayAttendData.find( + (d) => d.user.id === attendData.user.id, + ) + if (lastSundayData) { + return { + user: attendData.user, + worshipSchedule: { id: selectedScheduleId } as WorshipSchedule, + isAttend: lastSundayData.isAttend, + memo: lastSundayData.memo, + } as AttendData + } + return attendData + }) + + console.log("updatedAttendData", updatedAttendData) + setSoonAttendData(updatedAttendData) + success("지난주 일요일 출석 데이터를 불러왔습니다.") + } + return { groupInfo, worshipScheduleList, @@ -76,5 +145,6 @@ export default function useAttendance() { soonAttendData, setSoonAttendData, getAttendData, + loadLastSundayAttendance, } }