diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 956d189..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/client/src/app/admin/darak/people/page.tsx b/client/src/app/admin/darak/people/page.tsx index cf0af1c..b65ec5a 100644 --- a/client/src/app/admin/darak/people/page.tsx +++ b/client/src/app/admin/darak/people/page.tsx @@ -21,6 +21,7 @@ import { get, put } from "@/config/api" import { type User } from "@server/entity/user" import { Community } from "@server/entity/community" import { MouseEvent, useEffect, useRef, useState } from "react" +import UserSearch from "@/components/UserSearch" export default function People() { const [communityList, setCommunityList] = useState([]) @@ -57,7 +58,7 @@ export default function People() { async function fetchCommunityUserList(communityId: number) { const communityUserListData = await get( - `/admin/community/user-list/${communityId}` + `/admin/community/user-list/${communityId}`, ) setChildCommunityList(communityUserListData) } @@ -66,7 +67,7 @@ export default function People() { const communityListData = await get("/admin/community") setCommunityList(communityListData) const noCommunityUserData = await get( - "/admin/community/no-community-user-list" + "/admin/community/no-community-user-list", ) setNoCommunityUser(noCommunityUserData) @@ -121,7 +122,7 @@ export default function People() { async function saveCommunityLeader( community: Community, - leaderId: number | null + leaderId: number | null, ) { await put("/admin/community/save-leader", { groupId: community.id, @@ -132,7 +133,7 @@ export default function People() { async function saveCommunityDeputyLeader( community: Community, - deputyLeaderId: number | null + deputyLeaderId: number | null, ) { await put("/admin/community/save-deputy-leader", { groupId: community.id, @@ -207,7 +208,7 @@ export default function People() { function CommunityBox({ displayCommunity }: { displayCommunity: Community }) { const myCommunity = childCommunityList.find( - (community) => community.id === displayCommunity.id + (community) => community.id === displayCommunity.id, ) function onClickCommunity(e: MouseEvent) { @@ -338,7 +339,7 @@ export default function People() { onChange={(e) => { saveCommunityDeputyLeader( displayCommunity, - e.target.value as number + e.target.value as number, ) }} > @@ -408,8 +409,28 @@ export default function People() { return `${getParentCommunityName(community.parent)} > ${community.name}` } + const handleSelectUser = (user: User) => { + if (!user.community) { + alert("미배정 사용자입니다.") + return + } + + const parentId = user.community.parent ? user.community.parent.id : null + + if (!parentId) { + setSelectedRootCommunity(null) + } else { + const parentCommunity = communityList.find((c) => c.id === parentId) + if (parentCommunity) { + setSelectedRootCommunity(parentCommunity) + } else { + console.warn("상위 그룹을 찾을 수 없습니다.") + } + } + } + return ( - + - 0 ? "warning" : "success"} - /> + + + - - {/* 미배정 사용자 영역 */} - - - 미배정 사용자 ({noCommunityUser.length}명) - - - 다락방에 속하지 않은 사용자들입니다. - - + {/* 미배정 사용자 영역 */} + - {noCommunityUser.map((user) => UserBox({ user }))} - - + + 미배정 사용자 ({noCommunityUser.length}명) + + + 다락방에 속하지 않은 사용자들입니다. + + + {noCommunityUser.map((user) => UserBox({ user }))} + + + {/* 커뮤니티 영역 */} {/* 네비게이션 */} @@ -578,7 +600,6 @@ export default function People() { )} - {/* 드래그 중인 사용자 표시 */} {selectedUser.current && selectedUser.current.id && ( } recentAbsentees: Array<{ @@ -225,10 +223,10 @@ function index() { - {dashboardData.statistics.weekly.newFamilyPercent}% + {dashboardData.statistics.weekly.newFamilyRegistrants}명 - 새가족 비율 + 새가족 등록자 @@ -242,106 +240,174 @@ function index() { 출석 현황 (최근 4주) - - {dashboardData.statistics.last4Weeks.map((week, index) => { - const maxCount = Math.max( - ...dashboardData.statistics.last4Weeks.flatMap((w) => [ - w.genderCount.male, - w.genderCount.female, - ]), - 1 - ) + + + {(() => { + const data = dashboardData.statistics.last4Weeks.map( + (w) => ({ + date: w.date, + male: w.genderCount.male, + female: w.genderCount.female, + total: w.genderCount.male + w.genderCount.female, + }), + ) + const maxVal = + Math.max(...data.map((d) => d.total), 1) * 1.2 - return ( - - - {week.date} - - - {/* Man Bar */} - - - {week.genderCount.male} - - 0 ? "4px" : "0px", - }} - /> - - {/* Woman Bar */} - - - {week.genderCount.female} - - 0 ? "4px" : "0px", - }} - /> - - - - ) - })} - + const getX = (index: number) => { + const sectionWidth = 800 / data.length + return index * sectionWidth + sectionWidth / 2 + } + const getY = (val: number) => 250 - (val / maxVal) * 200 + + const malePath = data + .map( + (d, i) => + `${i === 0 ? "M" : "L"} ${getX(i)} ${getY(d.male)}`, + ) + .join(" ") + const femalePath = data + .map( + (d, i) => + `${i === 0 ? "M" : "L"} ${getX(i)} ${getY(d.female)}`, + ) + .join(" ") + const totalPath = data + .map( + (d, i) => + `${i === 0 ? "M" : "L"} ${getX(i)} ${getY(d.total)}`, + ) + .join(" ") + + return ( + <> + {/* Grid lines */} + + + {/* Total Line */} + + + {/* Male Line */} + + + {/* Female Line */} + + + {/* Points and Labels */} + {data.map((d, i) => { + const maleHigher = d.male >= d.female + + return ( + + + {d.date} + + + {/* Total */} + + + {d.total} + + + {/* Male */} + + + {d.male} + + + {/* Female */} + + + {d.female} + + + ) + })} + + ) + })()} + + + + + 전체 + { checkValidAccess() }, []) - const { getKakaoToken } = useKakaoHook() - - async function requestKakaoLogin() { - const token = await getKakaoToken() - if (!token) { - alert("카카오 로그인에 실패했습니다. 다시 시도해주세요.") - return - } + useEffect(() => { + registerKakaoLogin(kakaoToken!) + }, [kakaoToken]) + async function registerKakaoLogin(token: string) { try { - const { message, error } = await post("/soon/register-kakao-login", { + const { + data: { message, error }, + } = await axios.post("/soon/register-kakao-login", { userId, - kakaoId: token, + kakaoToken: token, }) alert(message || error) if (message) { window.close() } - } catch (error) { + } catch (error: any) { if (error.response) { alert( error.response.data.error || - "등록에 실패했습니다. 관리자에게 문의 해주세요." + "등록에 실패했습니다. 관리자에게 문의 해주세요.", ) } } } + async function requestKakaoLogin() { + try { + await executeKakaoLogin("/common/kakao-user-connect") + } catch (error: any) { + alert("카카오 로그인에 실패했습니다. 다시 시도해주세요.") + } + } + async function checkValidAccess() { try { - const { message, error } = await get( - `/soon/isValid-kakao-login-register?userId=${userId}` - ) + const { + data: { message, error }, + } = await axios.get(`/soon/isValid-kakao-login-register?userId=${userId}`) if (error) { alert(error) return false } - } catch (error) { + } catch (error: any) { alert("만료 되었거나 잘못된 접근입니다.") return false } diff --git a/client/src/app/common/login/page.tsx b/client/src/app/common/login/page.tsx index 5a28c41..6a8de1f 100644 --- a/client/src/app/common/login/page.tsx +++ b/client/src/app/common/login/page.tsx @@ -7,6 +7,7 @@ import useAuth from "@/hooks/useAuth" import { Button, Stack } from "@mui/material" import { NotificationMessage } from "@/state/notification" import { useRouter, useSearchParams } from "next/navigation" +import useKakaoHook from "@/hooks/useKakao" export default function LoginPage() { return ( @@ -19,21 +20,32 @@ export default function LoginPage() { function Login() { const { push } = useRouter() const searchParams = useSearchParams() - const { isLogin, login } = useAuth() + const { getKakaoTokenFromAuthCode, login, isLogin } = useAuth() + const { executeKakaoLogin } = useKakaoHook() const setNotificationMessage = useSetAtom(NotificationMessage) useEffect(() => { - const returnUrl = searchParams.get("returnUrl") || "/" - if (isLogin) { - push(returnUrl) + const code = searchParams.get("code") + if (code) { + //카카오에서 리다이렉트된 경우 + getKakaoTokenFromAuthCode(code).then((kakaoToken) => { + login(kakaoToken).then(() => { + const returnUrl = searchParams.get("state") + push(returnUrl || "/") + }) + }) + } else if (isLogin) { + const returnUrl = searchParams.get("returnUrl") + push(returnUrl || "/") } - }, [isLogin]) + }, [searchParams.get("code"), isLogin]) async function handleLogin() { try { - await login() - } catch { - setNotificationMessage("등록되지 않은 사용자 입니다.") + const returnUrl = searchParams.get("returnUrl") || "/" + await executeKakaoLogin(returnUrl) + } catch (error) { + setNotificationMessage("카카오 로그인 실패") } } diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index a6c86dc..068e2ec 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -18,7 +18,7 @@ export default function RootLayout({ global.location.href = "kakaotalk://web/openExternal?url=" + encodeURIComponent( - `https://nuon.iubns.net${global.location?.pathname}${global.location?.search}` + `https://nuon.iubns.net${global.location?.pathname}${global.location?.search}`, ) isKakaoBrowser = true } @@ -52,10 +52,11 @@ export default function RootLayout({ - {isKakaoBrowser ? (
카카오톡 브라우저에서는 사용할 수 없습니다.
diff --git a/client/src/app/leader/components/Header/index.tsx b/client/src/app/leader/components/Header/index.tsx index d67a0b4..9e6817a 100644 --- a/client/src/app/leader/components/Header/index.tsx +++ b/client/src/app/leader/components/Header/index.tsx @@ -36,15 +36,16 @@ export default function Header() { path: "/leader/attendance", type: "menu", }, + /*Todo: 다음 수련회때 다시 키기 { title: "순원 수련회 접수 조회", icon: , path: "/leader/retreat-attendance", type: "menu", - }, + },*/ ] - if (authUserData?.role.VillageLeader) { + if (authUserData?.role.VillageLeader || authUserData?.role.Admin) { menu.push({ title: "전체 출석 조회", icon: , @@ -53,6 +54,15 @@ export default function Header() { }) } + if (authUserData?.role.NewcomerManager || authUserData?.role.Admin) { + menu.push({ + title: "새가족 관리", + icon: , + path: "/leader/newcomer/management", + type: "menu", + }) + } + return ( +} + +interface EducationResponse { + worshipSchedules: WorshipSchedule[] + newcomers: NewcomerEducation[] +} + +// 강의 타입별 색상 (value 기준) +const lectureColors: Record = { + "": "transparent", + OT: "#b8f85d", + L1: "#fdf171", + L2: "#fdf171", + L3: "#fdf171", + L4: "#fdf171", + L5: "#fdf171", +} + +// value: API로 보내는 값, label: 화면에 표시하는 값 +const lectureOptions = [ + { value: "", label: "-" }, + { value: "OT", label: "OT" }, + { value: "L1", label: "1강" }, + { value: "L2", label: "2강" }, + { value: "L3", label: "3강" }, + { value: "L4", label: "4강" }, + { value: "L5", label: "5강" }, +] + +// value → label 변환 +const getLectureLabel = (value: string) => { + return lectureOptions.find((o) => o.value === value)?.label || value +} + +// 테이블 셀 너비 상수 +const NAME_CELL_WIDTH = 150 +const DATE_CELL_WIDTH = 100 + +export default function NewcomerEducationPage() { + const { isLeaderIfNotExit } = useAuth() + const [educationData, setEducationData] = useState( + null, + ) + const [loading, setLoading] = useState(true) + const [savingCell, setSavingCell] = useState(null) // 저장 중인 셀 표시 + const setNotificationMessage = useSetAtom(NotificationMessage) + + useEffect(() => { + isLeaderIfNotExit("/leader/newcomer/education") + fetchEducationData() + }, []) + + async function fetchEducationData() { + try { + setLoading(true) + const { data } = await axios.get("/newcomer/education") + + // 출석률 기준 정렬 + const sortedNewcomers = sortByAttendanceRate( + data.newcomers, + data.worshipSchedules, + ) + setEducationData({ + ...data, + newcomers: sortedNewcomers, + }) + } catch (error) { + console.error("Error fetching education data:", error) + } finally { + setLoading(false) + } + } + + // OT 날짜부터 출석률 계산 및 정렬 + function sortByAttendanceRate( + newcomers: NewcomerEducation[], + schedules: WorshipSchedule[], + ): NewcomerEducation[] { + return [...newcomers].sort((a, b) => { + const rateA = calculateAttendanceRate(a, schedules) + const rateB = calculateAttendanceRate(b, schedules) + return rateB - rateA // 높은 순으로 정렬 + }) + } + + // 출석률 계산: OT 날짜부터 현재까지의 출석 비율 + function calculateAttendanceRate( + newcomer: NewcomerEducation, + schedules: WorshipSchedule[], + ): number { + const educationEntries = Object.entries(newcomer.education).filter( + ([_, record]) => record !== null, + ) + + // OT 기록 찾기 + const otEntry = educationEntries.find( + ([_, record]) => record?.lectureType === "OT", + ) + if (!otEntry) return -1 // OT 없으면 맨 아래로 + + const [otDate] = otEntry + + // OT 이후의 스케줄 수 계산 (날짜 기준) + const schedulesAfterOT = schedules.filter((s) => s.date >= otDate) + + if (schedulesAfterOT.length === 0) return 0 + + // 출석 횟수: OT 날짜 이후에 기록이 있는 날짜 수 + const attendedDates = educationEntries.filter(([date]) => date >= otDate) + + return attendedDates.length / schedulesAfterOT.length + } + + async function handleLectureChange( + newcomerId: string, + worshipScheduleId: number, + lectureType: string, + ) { + const cellKey = `${newcomerId}-${worshipScheduleId}` + setSavingCell(cellKey) + + try { + await axios.put(`/newcomer/${newcomerId}/education`, { + lectureType: lectureType || null, + worshipScheduleId, + }) + + // 로컬 상태 업데이트 + setEducationData((prev) => { + if (!prev) return prev + + return { + ...prev, + newcomers: prev.newcomers.map((newcomer) => { + if (newcomer.id !== newcomerId) return newcomer + + // 해당 날짜의 스케줄 찾기 + const schedule = prev.worshipSchedules.find( + (s) => s.id === worshipScheduleId, + ) + if (!schedule) return newcomer + + const newEducation = { ...newcomer.education } + if (lectureType) { + newEducation[schedule.date] = { + id: "", + lectureType, + worshipScheduleId, + memo: null, + } + } else { + delete newEducation[schedule.date] + } + + return { + ...newcomer, + education: newEducation, + } + }), + } + }) + } catch (error) { + console.error("Error saving education data:", error) + setNotificationMessage("저장 중 오류가 발생했습니다.") + } finally { + setSavingCell(null) + } + } + + function getLectureValue( + newcomer: NewcomerEducation, + worshipScheduleId: number, + ): string { + const schedule = educationData?.worshipSchedules.find( + (s) => s.id === worshipScheduleId, + ) + if (!schedule) return "" + + const record = newcomer.education[schedule.date] + return record?.lectureType || "" + } + + // 해당 새신자가 이미 사용한 강의 타입 목록 (현재 날짜 제외) + function getUsedLectureTypes( + newcomer: NewcomerEducation, + currentScheduleId: number, + ): string[] { + const currentSchedule = educationData?.worshipSchedules.find( + (s) => s.id === currentScheduleId, + ) + if (!currentSchedule) return [] + + return Object.entries(newcomer.education) + .filter(([date, record]) => { + if (!record) return false + // 현재 날짜는 제외 (수정 가능하게) + if (date === currentSchedule.date) return false + return true + }) + .map(([_, record]) => record!.lectureType) + } + + if (loading) { + return ( + + 로딩 중... + + ) + } + + if (!educationData) { + return ( + + 데이터를 불러올 수 없습니다. + + ) + } + + return ( + + + {/* 상단 헤더 */} + + + + 새신자 교육 현황 + + + + + {/* 범례 */} + + + + + 강의: + + {lectureOptions + .filter((l) => l.value !== "") + .map((lecture) => ( + + ))} + + + + + {/* 출석 테이블 */} + + + + + 출석 현황 + + + + {/* Table Header */} + + + + 새신자 현황 + + + n.gender === "man").length}`} + size="small" + color="primary" + variant="outlined" + /> + n.gender === "woman").length}`} + size="small" + color="secondary" + variant="outlined" + /> + + + + {educationData.worshipSchedules.map((schedule) => ( + + + {schedule.date} + + + ))} + + + {/* Table Body */} + {educationData.newcomers.map((newcomer) => ( + + + + {newcomer.name} ({newcomer.yearOfBirth || "-"}) + + + + {educationData.worshipSchedules.map((schedule) => { + const currentValue = getLectureValue( + newcomer, + schedule.id, + ) + const cellKey = `${newcomer.id}-${schedule.id}` + const isSaving = savingCell === cellKey + const usedLectures = getUsedLectureTypes( + newcomer, + schedule.id, + ) + + return ( + + + + ) + })} + + ))} + + {educationData.newcomers.length === 0 && ( + + + 등록된 새신자가 없습니다. + + + )} + + + + + + + ) +} diff --git a/client/src/app/leader/newcomer/layout.tsx b/client/src/app/leader/newcomer/layout.tsx new file mode 100644 index 0000000..e679b1d --- /dev/null +++ b/client/src/app/leader/newcomer/layout.tsx @@ -0,0 +1,55 @@ +"use client" + +import { Stack, Tabs, Tab, Box } from "@mui/material" +import { usePathname, useRouter } from "next/navigation" + +const menuItems = [ + { label: "새신자 등록/조회", path: "/leader/newcomer/management" }, + { label: "교육 현황", path: "/leader/newcomer/education" }, + { label: "섬김이 관리", path: "/leader/newcomer/managers" }, +] + +export default function NewcomerLayout({ + children, +}: { + children: React.ReactNode +}) { + const pathname = usePathname() + const router = useRouter() + + // 현재 경로가 메뉴 아이템 중 하나와 일치하거나 시작하는지 확인 + const currentTab = menuItems.findIndex( + (item) => pathname === item.path || pathname.startsWith(item.path + "/"), + ) + + function handleTabChange(_: React.SyntheticEvent, newValue: number) { + router.push(menuItems[newValue].path) + } + + // 메인 페이지(/leader/newcomer)에서는 탭을 표시하지 않음 + if (pathname === "/leader/newcomer") { + return {children} + } + + return ( + + + + {menuItems.map((item) => ( + + ))} + + + {children} + + ) +} diff --git a/client/src/app/leader/newcomer/management/NewcomerFilter.tsx b/client/src/app/leader/newcomer/management/NewcomerFilter.tsx new file mode 100644 index 0000000..39560e6 --- /dev/null +++ b/client/src/app/leader/newcomer/management/NewcomerFilter.tsx @@ -0,0 +1,122 @@ +import { Box, Button, MenuItem, Stack, TextField } from "@mui/material" + +interface NewcomerFilterProps { + filterName: string + setFilterName: (value: string) => void + filterGender: "" | "man" | "woman" + setFilterGender: (value: "" | "man" | "woman") => void + filterMinYear: string + setFilterMinYear: (value: string) => void + filterMaxYear: string + setFilterMaxYear: (value: string) => void + clearFilters: () => void +} + +export default function NewcomerFilter({ + filterName, + setFilterName, + filterGender, + setFilterGender, + filterMinYear, + setFilterMinYear, + filterMaxYear, + setFilterMaxYear, + clearFilters, +}: NewcomerFilterProps) { + return ( + + + + 필터 + + + + + + + + 이름: + + setFilterName(e.target.value)} + variant="outlined" + sx={{ flex: 1 }} + /> + + + + + 성별: + + + setFilterGender(e.target.value as "" | "man" | "woman") + } + variant="outlined" + sx={{ flex: 1 }} + > + 전체 + + + + + + + + 생년: + + setFilterMinYear(e.target.value)} + variant="outlined" + type="number" + sx={{ width: "90px" }} + /> + + ~ + + setFilterMaxYear(e.target.value)} + variant="outlined" + type="number" + sx={{ width: "90px" }} + /> + + + + ) +} diff --git a/client/src/app/leader/newcomer/management/NewcomerForm.tsx b/client/src/app/leader/newcomer/management/NewcomerForm.tsx new file mode 100644 index 0000000..547fabf --- /dev/null +++ b/client/src/app/leader/newcomer/management/NewcomerForm.tsx @@ -0,0 +1,193 @@ +import { Box, Button, MenuItem, Stack, TextField } from "@mui/material" + +interface Manager { + id: string + user: { id: string; name: string } +} + +interface Newcomer { + id: string + name: string + yearOfBirth: number | null + phone: string | null + gender: "man" | "woman" | "" | null + newcomerManager?: { + id: string + user: { id: string; name: string } + } | null +} + +interface NewcomerFormProps { + selectedNewcomer: Newcomer + onDataChange: (key: string, value: any) => void + onSave: () => void + onDelete: () => void + onClear: () => void + managerList: Manager[] +} + +export default function NewcomerForm({ + selectedNewcomer, + onDataChange, + onSave, + onDelete, + onClear, + managerList, +}: NewcomerFormProps) { + return ( + + + + + {selectedNewcomer.id ? "정보 수정 중.." : "새로 입력 중.."} + + + {selectedNewcomer.id && ( + + )} + + + + + + + 이름 :{" "} + + onDataChange("name", e.target.value)} + variant="outlined" + size="small" + sx={{ flex: 1 }} + /> + + + + + 생년 :{" "} + + + onDataChange( + "yearOfBirth", + e.target.value ? parseInt(e.target.value) : null, + ) + } + variant="outlined" + size="small" + type="number" + placeholder="예: 1990" + sx={{ flex: 1 }} + /> + + + + + 연락처 :{" "} + + onDataChange("phone", e.target.value)} + variant="outlined" + size="small" + sx={{ flex: 1 }} + /> + + + + + 성별 : + + onDataChange("gender", e.target.value)} + variant="outlined" + size="small" + sx={{ flex: 1 }} + > + + + + + + + + 담당자 : + + { + const managerId = e.target.value + if (!managerId) { + onDataChange("newcomerManager", null) + } else { + const manager = managerList.find((m) => m.id === managerId) + onDataChange("newcomerManager", manager || null) + } + }} + variant="outlined" + size="small" + sx={{ flex: 1 }} + > + 없음 + {managerList.map((manager) => ( + + {manager.user.name} + + ))} + + + + ) +} diff --git a/client/src/app/leader/newcomer/management/NewcomerTable.tsx b/client/src/app/leader/newcomer/management/NewcomerTable.tsx new file mode 100644 index 0000000..f94fac6 --- /dev/null +++ b/client/src/app/leader/newcomer/management/NewcomerTable.tsx @@ -0,0 +1,117 @@ +import { + Box, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TableSortLabel, + Stack, +} from "@mui/material" + +interface Newcomer { + id: string + name: string + yearOfBirth: number | null + phone: string | null + gender: "man" | "woman" | "" | null + status: string +} + +interface NewcomerTableProps { + newcomerList: Newcomer[] + filteredNewcomerList: Newcomer[] + orderProperty: string + direction: "asc" | "desc" + onSortClick: (property: string) => void + onNewcomerSelect: (newcomer: any) => void +} + +export default function NewcomerTable({ + newcomerList, + filteredNewcomerList, + orderProperty, + direction, + onSortClick, + onNewcomerSelect, +}: NewcomerTableProps) { + return ( + + + 총 {filteredNewcomerList.length}명 (전체 {newcomerList.length}명) + + + + + + onSortClick("name")} + > + 이름 + + + + onSortClick("gender")} + > + 성별 + + + + onSortClick("yearOfBirth")} + > + 생년 + + + + onSortClick("phone")} + > + 전화번호 + + + + + + {filteredNewcomerList.map((newcomer) => ( + onNewcomerSelect(newcomer)} + sx={{ cursor: "pointer" }} + > + {newcomer.name} + + {newcomer.gender === "man" + ? "남" + : newcomer.gender === "woman" + ? "여" + : ""} + + + {newcomer.yearOfBirth === null || newcomer.yearOfBirth === 0 + ? "" + : newcomer.yearOfBirth} + + {newcomer.phone || ""} + + ))} + +
+
+ ) +} diff --git a/client/src/app/leader/newcomer/management/page.tsx b/client/src/app/leader/newcomer/management/page.tsx new file mode 100644 index 0000000..8ce3ad0 --- /dev/null +++ b/client/src/app/leader/newcomer/management/page.tsx @@ -0,0 +1,239 @@ +"use client" + +import { Stack } from "@mui/material" +import { useEffect, useState } from "react" +import { useSetAtom } from "jotai" +import axios from "@/config/axios" +import { NotificationMessage } from "@/state/notification" +import useAuth from "@/hooks/useAuth" +import NewcomerTable from "./NewcomerTable" +import NewcomerFilter from "./NewcomerFilter" +import NewcomerForm from "./NewcomerForm" + +interface Newcomer { + id: string + name: string + yearOfBirth: number | null + phone: string | null + gender: "man" | "woman" | "" | null + status: string + guider: { id: string; name: string } | null + newcomerManager: { + id: string + user: { id: string; name: string } + } | null + assignment: { id: number; name: string } | null + createdAt: string +} + +interface Manager { + id: string + user: { id: string; name: string } +} + +const emptyNewcomer: Newcomer = { + id: "", + name: "", + yearOfBirth: null, + phone: null, + gender: "man", + status: "NORMAL", + guider: null, + newcomerManager: null, + assignment: null, + createdAt: "", +} + +export default function NewcomerManagement() { + const { isLeaderIfNotExit } = useAuth() + const [newcomerList, setNewcomerList] = useState([]) + const [selectedNewcomer, setSelectedNewcomer] = + useState(emptyNewcomer) + const [orderProperty, setOrderProperty] = useState("name") + const [direction, setDirection] = useState<"asc" | "desc">("asc") + const setNotificationMessage = useSetAtom(NotificationMessage) + + // 필터 상태 + const [filterName, setFilterName] = useState("") + const [filterGender, setFilterGender] = useState<"" | "man" | "woman">("") + const [filterMinYear, setFilterMinYear] = useState("") + const [filterMaxYear, setFilterMaxYear] = useState("") + const [managerList, setManagerList] = useState([]) + + useEffect(() => { + isLeaderIfNotExit("/leader/newcomer/management") + fetchData() + }, []) + + async function fetchData() { + try { + const [newcomerRes, managerRes] = await Promise.all([ + axios.get("/newcomer"), + axios.get("/newcomer/managers"), + ]) + setNewcomerList(newcomerRes.data) + setManagerList(managerRes.data) + } catch (error) { + console.error("Error fetching data:", error) + setNotificationMessage("데이터 조회에 실패했습니다.") + } + } + + function clearSelectedNewcomer() { + setSelectedNewcomer(emptyNewcomer) + } + + function onChangeData(key: string, value: any) { + setSelectedNewcomer({ ...selectedNewcomer, [key]: value }) + } + + async function saveData() { + try { + if (selectedNewcomer.id) { + await axios.put(`/newcomer/${selectedNewcomer.id}`, { + name: selectedNewcomer.name, + yearOfBirth: selectedNewcomer.yearOfBirth, + gender: selectedNewcomer.gender, + phone: selectedNewcomer.phone, + newcomerManagerId: selectedNewcomer.newcomerManager?.id || null, + guiderId: selectedNewcomer.guider?.id || null, + assignmentId: selectedNewcomer.assignment?.id || null, + status: selectedNewcomer.status, + }) + setNotificationMessage("새신자 정보가 수정되었습니다.") + } else { + await axios.post("/newcomer", { + name: selectedNewcomer.name, + yearOfBirth: selectedNewcomer.yearOfBirth, + gender: selectedNewcomer.gender, + phone: selectedNewcomer.phone, + newcomerManagerId: selectedNewcomer.newcomerManager?.id || null, + }) + setNotificationMessage("새신자가 추가되었습니다.") + } + await fetchData() + clearSelectedNewcomer() + } catch (error) { + setNotificationMessage("저장 중 오류가 발생했습니다.") + } + } + + async function deleteNewcomer() { + if (selectedNewcomer.id && confirm("정말로 삭제하시겠습니까?")) { + try { + await axios.delete(`/newcomer/${selectedNewcomer.id}`) + setNotificationMessage("새신자가 삭제되었습니다.") + clearSelectedNewcomer() + await fetchData() + } catch (error) { + setNotificationMessage("삭제 중 오류가 발생했습니다.") + } + } + } + + function clearFilters() { + setFilterName("") + setFilterGender("") + setFilterMinYear("") + setFilterMaxYear("") + } + + function orderingNewcomerList() { + return newcomerList + .filter((newcomer) => { + if (!newcomer.name) return false + + if ( + filterName && + !newcomer.name.toLowerCase().includes(filterName.toLowerCase()) + ) { + return false + } + + if (filterGender && newcomer.gender !== filterGender) { + return false + } + + if ( + filterMinYear && + newcomer.yearOfBirth && + newcomer.yearOfBirth < parseInt(filterMinYear) + ) { + return false + } + if ( + filterMaxYear && + newcomer.yearOfBirth && + newcomer.yearOfBirth > parseInt(filterMaxYear) + ) { + return false + } + + return true + }) + .sort((a, b) => { + if (orderProperty === "name") { + if (direction === "asc") { + return a.name.localeCompare(b.name) + } + return b.name.localeCompare(a.name) + } + if (orderProperty === "yearOfBirth") { + const aYear = a.yearOfBirth || 0 + const bYear = b.yearOfBirth || 0 + if (direction === "asc") { + return aYear - bYear + } + return bYear - aYear + } + return 0 + }) + } + + function handleSortClick(property: string) { + if (orderProperty === property) { + setDirection(direction === "asc" ? "desc" : "asc") + } else { + setOrderProperty(property) + setDirection("asc") + } + } + + const filteredNewcomers = orderingNewcomerList() + + return ( + + + + + + + + + + ) +} diff --git a/client/src/app/leader/newcomer/managers/page.tsx b/client/src/app/leader/newcomer/managers/page.tsx new file mode 100644 index 0000000..90129a3 --- /dev/null +++ b/client/src/app/leader/newcomer/managers/page.tsx @@ -0,0 +1,344 @@ +"use client" + +import { + Box, + Button, + Stack, + Typography, + Paper, + TextField, + Card, + CardContent, + Avatar, + Tooltip, + IconButton, + Chip, +} from "@mui/material" +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 CloseIcon from "@mui/icons-material/Close" +import PersonAddIcon from "@mui/icons-material/PersonAdd" + +interface User { + id: string + name: string + yearOfBirth: number | null + gender: string | null +} + +interface Newcomer { + id: string + name: string + yearOfBirth: number | null +} + +interface NewcomerManager { + id: string + user: User + newcomers: Newcomer[] +} + +export default function ManagerPage() { + const { isLeaderIfNotExit } = useAuth() + const [userList, setUserList] = useState([]) + const [managerList, setManagerList] = useState([]) + const [searchName, setSearchName] = useState("") + const [loading, setLoading] = useState(true) + const setNotificationMessage = useSetAtom(NotificationMessage) + + useEffect(() => { + isLeaderIfNotExit("/leader/newcomer/managers") + fetchData() + }, []) + + async function fetchData() { + try { + setLoading(true) + const [usersRes, managersRes] = await Promise.all([ + axios.get("/newcomer/users"), + axios.get("/newcomer/managers"), + ]) + setUserList(usersRes.data) + setManagerList(managersRes.data) + } catch (error) { + console.error("Error fetching data:", error) + } finally { + setLoading(false) + } + } + + async function addManager(userId: string) { + try { + await axios.post("/newcomer/managers", { userId }) + setNotificationMessage("담당자로 지정되었습니다.") + await fetchData() + } catch (error) { + console.error("Error adding manager:", error) + setNotificationMessage("담당자 지정 중 오류가 발생했습니다.") + } + } + + async function removeManager(managerId: string) { + if (!confirm("정말로 담당자를 해제하시겠습니까?")) return + try { + await axios.delete(`/newcomer/managers/${managerId}`) + setNotificationMessage("담당자가 해제되었습니다.") + await fetchData() + } catch (error) { + console.error("Error removing manager:", error) + setNotificationMessage("담당자 해제 중 오류가 발생했습니다.") + } + } + + function isManager(userId: string) { + return managerList.some((manager) => manager.user.id === userId) + } + + const filteredUsers = userList.filter((user) => + user.name.toLowerCase().includes(searchName.toLowerCase()), + ) + + if (loading) { + return ( + + 로딩 중... + + ) + } + + return ( + + + {/* 헤더 */} + + 섬김이 관리 + + + + {/* 왼쪽: 사용자 목록 */} + + + 사용자 목록 + + setSearchName(e.target.value)} + fullWidth + sx={{ mb: 2 }} + /> + + {filteredUsers.map((user) => ( + + + {user.name} ({user.yearOfBirth || "-"}) + + {isManager(user.id) ? ( + + 이미 담당자 + + ) : ( + + )} + + ))} + {filteredUsers.length === 0 && ( + + 검색 결과가 없습니다. + + )} + + + + {/* 오른쪽: 담당자 박스들 */} + + {managerList.map((manager) => ( + + {/* 담당자 헤더 */} + + + {manager.user.name} + + removeManager(manager.id)} + sx={{ color: "#d32f2f" }} + > + + + + + {/* 담당 새신자 목록 */} + + 담당 새신자 ({manager.newcomers?.length || 0}명) + + + {manager.newcomers?.map((newcomer) => ( + + + + + {newcomer.name.charAt(0)} + + + {newcomer.name} + + + + + ))} + {(!manager.newcomers || manager.newcomers.length === 0) && ( + + 담당 새신자 없음 + + )} + + + ))} + + {/* 빈 상태 */} + {managerList.length === 0 && ( + + + + 담당자를 추가해주세요 + + + )} + + + + + ) +} diff --git a/client/src/app/leader/retreat-attendance/RetreatAttendanceCard.tsx b/client/src/app/leader/retreat-attendance/RetreatAttendanceCard.tsx index 4ac73c3..af1b311 100644 --- a/client/src/app/leader/retreat-attendance/RetreatAttendanceCard.tsx +++ b/client/src/app/leader/retreat-attendance/RetreatAttendanceCard.tsx @@ -16,7 +16,7 @@ interface RetreatAttendanceCardProps { export default function RetreatAttendanceCard({ soon, }: RetreatAttendanceCardProps) { - const isRegistered = !!soon.retreatAttend + const isRegistered = !!soon.retreatAttend && !soon.retreatAttend.isCanceled return ( Number.parseFloat(a.time.replace(":", ".")) - - Number.parseFloat(b.time.replace(":", ".")) + Number.parseFloat(b.time.replace(":", ".")), ) const cars = data.filter( - (info) => info.howToMove === HowToMove.driveCarWithPerson + (info) => info.howToMove === HowToMove.driveCarWithPerson, ) setCarList(cars) const rideUsers = data.filter( (info) => (info.howToMove === HowToMove.rideCar && !info.rideCarInfo) || - (info.howToMove === HowToMove.goAlone && !info.rideCarInfo) + (info.howToMove === HowToMove.goAlone && !info.rideCarInfo), ) setRideUserList(rideUsers) }) @@ -135,13 +135,12 @@ function Carpooling() { const target = e.target as HTMLElement const shiftX = e.clientX - target.getBoundingClientRect().left const shiftY = e.clientY - target.getBoundingClientRect().top - console.log(e) setSelectedInfo(info) setShiftPosition({ x: shiftX, y: shiftY }) }} onDoubleClick={() => { router.push( - `/retreat/admin/edit-user-data?retreadAttendId=${info.retreatAttend.id}` + `/retreat/admin/edit-user-data?retreadAttendId=${info.retreatAttend.id}`, ) }} > @@ -209,7 +208,7 @@ function Carpooling() { {rideUserList .filter( (info) => - info.day === selectedDay && info.inOutType === selectedInOut + info.day === selectedDay && info.inOutType === selectedInOut, ) .map((info) => getRowOfInfo(info))} @@ -226,7 +225,7 @@ function Carpooling() { {carList .filter( (info) => - info.day === selectedDay && info.inOutType === selectedInOut + info.day === selectedDay && info.inOutType === selectedInOut, ) .map((car) => ( { router.push( - `/retreat/admin/edit-user-data?retreadAttendId=${car.retreatAttend.id}` + `/retreat/admin/edit-user-data?retreadAttendId=${car.retreatAttend.id}`, ) }} > diff --git a/client/src/app/retreat/admin/check-status/page.tsx b/client/src/app/retreat/admin/check-status/page.tsx index 1714a4c..4b6b922 100644 --- a/client/src/app/retreat/admin/check-status/page.tsx +++ b/client/src/app/retreat/admin/check-status/page.tsx @@ -89,10 +89,10 @@ export default function CheckStatus(props: any) { //@ts-ignore (err) => { if (err) { - return console.log(err) + return console.error(err) } Quagga.start() - } + }, ) Quagga.onDetected(_onDetected) //@ts-ignore @@ -106,7 +106,7 @@ export default function CheckStatus(props: any) { 0, 0, parseInt(drawingCanvas.getAttribute("width")), - parseInt(drawingCanvas.getAttribute("height")) + parseInt(drawingCanvas.getAttribute("height")), ) result.boxes //@ts-ignore @@ -132,7 +132,7 @@ export default function CheckStatus(props: any) { result.line, { x: "x", y: "y" }, drawingCtx, - { color: "red", lineWidth: 3 } + { color: "red", lineWidth: 3 }, ) } } diff --git a/client/src/app/retreat/hooks/useRetreat.ts b/client/src/app/retreat/hooks/useRetreat.ts index d364cb8..a465f1d 100644 --- a/client/src/app/retreat/hooks/useRetreat.ts +++ b/client/src/app/retreat/hooks/useRetreat.ts @@ -14,7 +14,7 @@ export default function useRetreat() { const { login, isLogin, authUserData } = useAuth() interface JoinNuonRequest { - kakaoId: number + kakaoToken: string name: string yearOfBirth: number gender: "man" | "woman" @@ -24,7 +24,7 @@ export default function useRetreat() { async function updateNuon(request: JoinNuonRequest) { if (!isLogin) { await axios.post("/retreat/join", request) - await login(request.kakaoId) + await login(request.kakaoToken) } else { return axios.post("/auth/edit-my-information", { ...request, diff --git a/client/src/app/retreat/login/page.tsx b/client/src/app/retreat/login/page.tsx index cbd57b6..18d8337 100644 --- a/client/src/app/retreat/login/page.tsx +++ b/client/src/app/retreat/login/page.tsx @@ -2,21 +2,20 @@ import { Stack } from "@mui/material" import RetreatButton from "../components/Button" -import useAuth from "@/hooks/useAuth" import { useRouter } from "next/navigation" import usePageColor from "@/hooks/usePageColor" import useBodyOverflowHidden from "@/hooks/useBodyOverflowHidden" +import useKakaoHook from "@/hooks/useKakao" export default function RetreatLogin() { useBodyOverflowHidden() usePageColor("#2F3237") - const { login } = useAuth() + const { executeKakaoLogin } = useKakaoHook() const { push } = useRouter() async function handleKakaoLogin() { try { - await login() - push("/retreat") + await executeKakaoLogin("/retreat") } catch { push("/retreat?newUser=true") } diff --git a/client/src/app/retreat/steps/first.tsx b/client/src/app/retreat/steps/first.tsx index 4a697b7..f7d5897 100644 --- a/client/src/app/retreat/steps/first.tsx +++ b/client/src/app/retreat/steps/first.tsx @@ -26,7 +26,7 @@ export default function FirstStep() { "-" + data.phone.slice(3, 7) + "-" + - data.phone.slice(7, 11) + data.phone.slice(7, 11), ) }) }, [isLogin]) @@ -43,7 +43,7 @@ export default function FirstStep() { } try { await updateNuon({ - kakaoId: kakaoToken, + kakaoToken: kakaoToken, name, yearOfBirth: parseInt(birthYear), phone: phone.replaceAll("-", ""), @@ -51,7 +51,8 @@ export default function FirstStep() { }) } catch (e) { alert( - "정보 등록에 실패했습니다. 새로고침후 다시 시도해주세요." + e.toString() + "정보 등록에 실패했습니다. 새로고침후 다시 시도해주세요." + + e.toString(), ) return } diff --git a/client/src/components/Header/index.tsx b/client/src/components/Header/index.tsx index 672ca2f..7c5a954 100644 --- a/client/src/components/Header/index.tsx +++ b/client/src/components/Header/index.tsx @@ -27,6 +27,7 @@ export default function Header() { path: "/common/myPage", type: "menu", }, + /*Todo: 수련회 신청 기간에 맞춰서 다시 열기 { type: "divider" }, { title: "2026 겨울 수련회 신청", @@ -34,6 +35,7 @@ export default function Header() { path: "/retreat", type: "menu", }, + */ ] if (authUserData?.role.Leader) { diff --git a/client/src/components/UserSearch/index.tsx b/client/src/components/UserSearch/index.tsx new file mode 100644 index 0000000..46332ee --- /dev/null +++ b/client/src/components/UserSearch/index.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect, useMemo } from "react" +import { TextField, Autocomplete, Box, Typography } from "@mui/material" +import axios from "@/config/axios" +import { type User } from "@server/entity/user" +import { debounce } from "lodash" + +interface UserSearchProps { + onSelectUser: (user: User) => void +} + +export default function UserSearch({ onSelectUser }: UserSearchProps) { + const [open, setOpen] = useState(false) + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + const [inputValue, setInputValue] = useState("") + + const fetchUsers = useMemo( + () => + debounce( + async ( + request: { input: string }, + callback: (results?: User[]) => void, + ) => { + try { + const { data } = await axios.get( + `/admin/community/search-user?name=${request.input}`, + ) + callback(data) + } catch (error) { + console.error(error) + callback([]) + } + }, + 300, + ), + [], + ) + + useEffect(() => { + let active = true + + if (inputValue === "") { + setOptions([]) + return undefined + } + + setLoading(true) + fetchUsers({ input: inputValue }, (results) => { + if (active && results) { + setOptions(results) + } + setLoading(false) + }) + + return () => { + active = false + } + }, [inputValue, fetchUsers]) + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + isOptionEqualToValue={(option, value) => option.id === value.id} + getOptionLabel={(option) => `${option.name} (${option.yearOfBirth})`} + options={options} + loading={loading} + onInputChange={(event, newInputValue) => { + setInputValue(newInputValue) + }} + onChange={(event, newValue) => { + if (newValue) { + onSelectUser(newValue) + setOpen(false) + } + }} + renderOption={(props, option) => { + const { key, ...optionProps } = props + return ( +
  • + + + {option.name} ({option.yearOfBirth}) + + + {option.community ? option.community.name : "미배정"} + + +
  • + ) + }} + renderInput={(params) => ( + + )} + sx={{ width: 300 }} + /> + ) +} diff --git a/client/src/components/receipt/index.tsx b/client/src/components/receipt/index.tsx index bffe96e..2766bbd 100644 --- a/client/src/components/receipt/index.tsx +++ b/client/src/components/receipt/index.tsx @@ -11,7 +11,7 @@ import { Deposit } from "@server/entity/types" export default function Receipt() { const [retreatAttend, setRetreatAttend] = useState( - undefined + undefined, ) const [isEditMode, setEditMode] = useState(true) const [inOutData, setInOutData] = useState>([]) @@ -25,11 +25,6 @@ export default function Receipt() { if (!token) { return } - const userData = jwtDecode(token) - console.log(userData) - if (!token) { - return - } post("/auth/check-token", { token, }).then((response) => { diff --git a/client/src/config/axios.ts b/client/src/config/axios.ts index 345fc8a..3a1f406 100644 --- a/client/src/config/axios.ts +++ b/client/src/config/axios.ts @@ -1,24 +1,42 @@ import axios from "axios" -let PORT = 8000 -const getBaseUrl = () => { +export const GetUrl = () => { const target = process.env.NEXT_PUBLIC_API_TARGET + let SERVER_PORT = ":8000" + let CLIENT_PORT = ":8080" switch (target) { case "prod": - return process.env.NEXT_PUBLIC_PROD_SERVER + SERVER_PORT = ":8000" + CLIENT_PORT = "" + return { + host: process.env.NEXT_PUBLIC_PROD_SERVER, + serverPort: SERVER_PORT, + clientPort: CLIENT_PORT, + } case "dev": - PORT = 8001 - return process.env.NEXT_PUBLIC_DEV_SERVER + SERVER_PORT = ":8001" + CLIENT_PORT = "" + return { + host: process.env.NEXT_PUBLIC_DEV_SERVER, + serverPort: SERVER_PORT, + clientPort: CLIENT_PORT, + } case "local": default: - return process.env.NEXT_PUBLIC_LOCAL_SERVER + SERVER_PORT = ":8000" + CLIENT_PORT = ":8080" + return { + host: process.env.NEXT_PUBLIC_LOCAL_SERVER, + serverPort: SERVER_PORT, + clientPort: CLIENT_PORT, + } } } -const SERVER_URL = getBaseUrl() +const URL = GetUrl() -export const SERVER_FULL_PATH = `${SERVER_URL}:${PORT}` +export const SERVER_FULL_PATH = `${URL.host}${URL.serverPort}` const isBrowser = typeof window !== "undefined" @@ -35,7 +53,7 @@ axios.interceptors.request.use( }, (error) => { return Promise.reject(error) - } + }, ) axios.interceptors.response.use( @@ -46,11 +64,11 @@ axios.interceptors.response.use( if (error.response && error.response.status === 401) { if (isBrowser) { window.location.href = `/common/login?redirect=${encodeURIComponent( - window.location.pathname + window.location.pathname, )}` } } return Promise.reject(error) - } + }, ) export default axios diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts index a359696..6ba776d 100644 --- a/client/src/hooks/useAuth.ts +++ b/client/src/hooks/useAuth.ts @@ -4,7 +4,7 @@ import { jwtDecode } from "jwt-decode" import { useEffect } from "react" import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" import dayjs from "dayjs" -import useKakaoHook from "@/hooks/useKakao" +import { Role } from "@server/util/type" import axios from "@/config/axios" import { Community } from "@server/entity/community" import { useRouter } from "next/navigation" @@ -12,12 +12,6 @@ import { NotificationMessage } from "@/state/notification" export const JwtInformationAtom = atom(null) -export interface Role { - Admin: boolean - Leader: boolean - VillageLeader: boolean -} - //Todo: 서버와 통합할 수 있는 방법 찾아보기, 지금은 jwt type error로 인해 분리 export interface jwtPayload { id: string @@ -29,11 +23,10 @@ export interface jwtPayload { exp: number } const isLoginAtom = atom((get) => get(JwtInformationAtom) != null) -const kakaoTokenAtom = atom(null) +const kakaoTokenAtom = atom(null) export default function useAuth() { const { push } = useRouter() - const { getKakaoToken } = useKakaoHook() const isLogin = useAtomValue(isLoginAtom) const setNotificationMessage = useSetAtom(NotificationMessage) const [authUserData, setAuthUserData] = useAtom(JwtInformationAtom) @@ -69,19 +62,27 @@ export default function useAuth() { return null } - async function login(kakaoId?: number): Promise { - if (!kakaoId) { - kakaoId = await getKakaoToken() + async function getKakaoTokenFromAuthCode(code: string) { + const { data } = await axios.post("/auth/get-kakao-token", { + code: code, + }) + setKakaoToken(data.kakaoToken) + return data.kakaoToken + } + + async function login(kakaoToken: string): Promise { + if (!kakaoToken) { + throw new Error("카카오 토큰이 없습니다.") } - setKakaoToken(kakaoId) + //setKakaoToken(kakaoToken) const { data } = await axios.post( "/auth/login", { - kakaoId: kakaoId, + kakaoToken: kakaoToken, }, { withCredentials: true, - } + }, ) const { accessToken } = data localStorage.setItem("token", accessToken) @@ -145,5 +146,6 @@ export default function useAuth() { isAdminIfNotExit, logout, kakaoToken, + getKakaoTokenFromAuthCode, } } diff --git a/client/src/hooks/useKakao.ts b/client/src/hooks/useKakao.ts index af255dd..02319c8 100644 --- a/client/src/hooks/useKakao.ts +++ b/client/src/hooks/useKakao.ts @@ -1,3 +1,4 @@ +import { GetUrl } from "@/config/axios" import { useEffect } from "react" export default function useKakaoHook() { @@ -37,35 +38,16 @@ export default function useKakaoHook() { return false } - function getKakaoToken(): Promise { - return new Promise((resolve, reject) => { - if (!Kakao) { - alert("카카오 SDK가 로딩되지 않았습니다.\n잠시후 다시 눌러주세요.") - if (globalValue.Kakao) { - alert("globalValue.Kakao는 불러와짐") - } - return - } - Kakao.Auth.login({ - success: function (response: Response) { - Kakao.API.request({ - url: "/v2/user/me", - success: function (response: { id: number }) { - resolve(response.id) - }, - fail: function (error: any) { - reject(error) - }, - }) - }, - fail: function (error: any) { - reject(error) - }, - }) + async function executeKakaoLogin(redirectUri: string = "") { + const URL = GetUrl() + await Kakao.Auth.authorize({ + redirectUri: `${URL.host}${URL.clientPort}/common/login`, + state: redirectUri, }) + return } return { - getKakaoToken, + executeKakaoLogin, } } diff --git a/server/src/entity/newcomer/newcomer.ts b/server/src/entity/newcomer/newcomer.ts new file mode 100644 index 0000000..6427294 --- /dev/null +++ b/server/src/entity/newcomer/newcomer.ts @@ -0,0 +1,81 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + OneToMany, + CreateDateColumn, + DeleteDateColumn, + UpdateDateColumn, +} from "typeorm" +import { User } from "../user" +import { Community } from "../community" +import { NewcomerStatus } from "../types" +import { NewcomerEducation } from "./newcomerEducation" +import { NewcomerManager } from "./newcomerManager" + +@Entity() +export class Newcomer { + @PrimaryGeneratedColumn("uuid") + id: string + + @Column() + name: string + + @Column({ nullable: true }) + yearOfBirth: number + + @Column({ nullable: true }) + gender: "man" | "woman" | "" + + @Column({ nullable: true }) + phone: string // 연락처 + + @ManyToOne(() => User) + @JoinColumn({ name: "guiderId" }) + guider: User // 인도자 + + @Column({ + type: "enum", + enum: NewcomerStatus, + default: NewcomerStatus.NORMAL, + }) + status: NewcomerStatus + + @Column({ nullable: true }) + promotionDate: string // 등반일 + + @ManyToOne(() => Community, { nullable: true }) + @JoinColumn({ name: "assignmentId" }) + assignment: Community // 배정 (등반 후 배정받는 순) + + @Column({ nullable: true }) + pendingDate: string // 보류일 + + @ManyToOne(() => NewcomerManager) + @JoinColumn({ name: "newcomerManagerId" }) + newcomerManager: NewcomerManager // 섬김이(담당자) + + @OneToMany(() => NewcomerEducation, (education) => education.newcomer) + educationRecords: NewcomerEducation[] + + @CreateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + }) + createdAt: Date + + @UpdateDateColumn({ + type: "timestamp", + default: () => "CURRENT_TIMESTAMP(6)", + onUpdate: "CURRENT_TIMESTAMP(6)", + }) + updatedAt: Date + + @DeleteDateColumn({ + type: "timestamp", + nullable: true, + }) + deletedAt: Date | null // 삭제일 +} diff --git a/server/src/entity/newcomer/newcomerEducation.ts b/server/src/entity/newcomer/newcomerEducation.ts new file mode 100644 index 0000000..489bdd2 --- /dev/null +++ b/server/src/entity/newcomer/newcomerEducation.ts @@ -0,0 +1,33 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from "typeorm" +import { Newcomer } from "./newcomer" +import { WorshipSchedule } from "../worshipSchedule" +import { EducationLecture } from "../types" + +@Entity() +export class NewcomerEducation { + @PrimaryGeneratedColumn("uuid") + id: string + + @ManyToOne(() => Newcomer, (newcomer) => newcomer.educationRecords) + @JoinColumn({ name: "newcomerId" }) + newcomer: Newcomer + + @ManyToOne(() => WorshipSchedule) + @JoinColumn({ name: "worshipScheduleId" }) + worshipSchedule: WorshipSchedule + + @Column({ + type: "enum", + enum: EducationLecture, + }) + lectureType: EducationLecture + + @Column({ type: "text", nullable: true }) + memo: string +} diff --git a/server/src/entity/newcomer/newcomerManager.ts b/server/src/entity/newcomer/newcomerManager.ts new file mode 100644 index 0000000..8df3c2d --- /dev/null +++ b/server/src/entity/newcomer/newcomerManager.ts @@ -0,0 +1,22 @@ +import { + Entity, + JoinColumn, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, +} from "typeorm" +import { User } from "../user" +import type { Newcomer } from "./newcomer" + +@Entity() +export class NewcomerManager { + @PrimaryGeneratedColumn("uuid") + id: string + + @OneToOne(() => User) + @JoinColumn({ name: "userId" }) + user: User + + @OneToMany("Newcomer", "newcomerManager") + newcomers: Newcomer[] +} diff --git a/server/src/entity/types.ts b/server/src/entity/types.ts index b003728..1f01716 100644 --- a/server/src/entity/types.ts +++ b/server/src/entity/types.ts @@ -57,3 +57,19 @@ export enum AttendStatus { ABSENT = "ABSENT", ETC = "ETC", } + +export enum EducationLecture { + OT = "OT", + L1 = "L1", + L2 = "L2", + L3 = "L3", + L4 = "L4", + L5 = "L5", +} + +export enum NewcomerStatus { + NORMAL = "NORMAL", + PROMOTED = "PROMOTED", + DELETED = "DELETED", + PENDING = "PENDING", +} diff --git a/server/src/migration/1769693832000-CreateNewcomer.ts b/server/src/migration/1769693832000-CreateNewcomer.ts new file mode 100644 index 0000000..c63b902 --- /dev/null +++ b/server/src/migration/1769693832000-CreateNewcomer.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CreateNewcomer1769693832000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 1. NewcomerManager 테이블 생성 + await queryRunner.query(` + CREATE TABLE \`newcomer_manager\` ( + \`id\` varchar(36) NOT NULL, + \`userId\` varchar(36) NULL, + PRIMARY KEY (\`id\`), + UNIQUE INDEX \`UQ_newcomer_manager_user\` (\`userId\`), + CONSTRAINT \`FK_newcomer_manager_user\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // 2. Newcomer 테이블 생성 + await queryRunner.query(` + CREATE TABLE \`newcomer\` ( + \`id\` varchar(36) NOT NULL, + \`name\` varchar(255) NOT NULL, + \`yearOfBirth\` int NULL, + \`gender\` varchar(10) NULL, + \`phone\` varchar(255) NULL COMMENT '연락처', + \`guiderId\` varchar(36) NULL COMMENT '인도자', + \`status\` enum ('NORMAL', 'PROMOTED', 'DELETED', 'PENDING') NOT NULL DEFAULT 'NORMAL', + \`promotionDate\` varchar(255) NULL COMMENT '등반일', + \`assignmentId\` int NULL COMMENT '배정 (등반 후 배정받는 순)', + \`pendingDate\` varchar(255) NULL COMMENT '보류일', + \`newcomerManagerId\` varchar(36) NULL COMMENT '섬김이(담당자)', + \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + \`deletedAt\` timestamp(6) NULL COMMENT '삭제일 (soft delete)', + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_newcomer_guider\` FOREIGN KEY (\`guiderId\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION, + CONSTRAINT \`FK_newcomer_manager\` FOREIGN KEY (\`newcomerManagerId\`) REFERENCES \`newcomer_manager\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION, + CONSTRAINT \`FK_newcomer_assignment\` FOREIGN KEY (\`assignmentId\`) REFERENCES \`community\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // 3. NewcomerEducation 테이블 생성 + await queryRunner.query(` + CREATE TABLE \`newcomer_education\` ( + \`id\` varchar(36) NOT NULL, + \`newcomerId\` varchar(36) NULL, + \`worshipScheduleId\` int NULL, + \`lectureType\` enum ('OT', 'L1', 'L2', 'L3', 'L4', 'L5') NOT NULL, + \`memo\` text NULL, + PRIMARY KEY (\`id\`), + CONSTRAINT \`FK_newcomer_education_newcomer\` FOREIGN KEY (\`newcomerId\`) REFERENCES \`newcomer\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT \`FK_newcomer_education_worship\` FOREIGN KEY (\`worshipScheduleId\`) REFERENCES \`worship_schedule\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`newcomer_education\``) + await queryRunner.query(`DROP TABLE \`newcomer\``) + await queryRunner.query(`DROP TABLE \`newcomer_manager\``) + } +} diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index 59c67b3..b85a517 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -14,6 +14,9 @@ import { AttendData } from "../entity/attendData" import { WorshipContest } from "../entity/event/worshipContest" import { AIChat } from "../entity/ai/aiChat" import { AIChatRoom } from "../entity/ai/aiChatRoom" +import { Newcomer } from "../entity/newcomer/newcomer" +import { NewcomerEducation } from "../entity/newcomer/newcomerEducation" +import { NewcomerManager } from "../entity/newcomer/newcomerManager" const dataSource = new DataSource(require("../../ormconfig.js")) @@ -34,5 +37,9 @@ export const aiChatDatabase = dataSource.getRepository(AIChat) export const aiChatRoomDatabase = dataSource.getRepository(AIChatRoom) export const worshipContestDatabase = dataSource.getRepository(WorshipContest) +export const newcomerDatabase = dataSource.getRepository(Newcomer) +export const newcomerEducationDatabase = + dataSource.getRepository(NewcomerEducation) +export const newcomerManagerDatabase = dataSource.getRepository(NewcomerManager) export default dataSource diff --git a/server/src/routes/admin/ai.ts b/server/src/routes/admin/ai.ts index 23c5e73..ef81c58 100644 --- a/server/src/routes/admin/ai.ts +++ b/server/src/routes/admin/ai.ts @@ -42,7 +42,6 @@ router.post("/ask", async (req, res) => { responseChat.room = chatRoom responseChat.createdAt = new Date() if (responseChat.message.includes("```sql")) { - console.log("query:", responseChat.message) responseChat.type = ChatType.SYSTEM // 쿼리는 시스템으로 저장 chatRoom.chats.push(responseChat) const sqlResult = await AiModel.callSql(responseChat.message) diff --git a/server/src/routes/admin/communityRouter.ts b/server/src/routes/admin/communityRouter.ts index 9d43903..4765588 100644 --- a/server/src/routes/admin/communityRouter.ts +++ b/server/src/routes/admin/communityRouter.ts @@ -1,6 +1,6 @@ import express from "express" import { communityDatabase, userDatabase } from "../../model/dataSource" -import { IsNull, Not } from "typeorm" +import { IsNull, Like, Not } from "typeorm" const router = express.Router() @@ -79,6 +79,39 @@ router.get("/user-list/:groupId", async (req, res) => { res.send(groupList) }) +router.get("/search-user", async (req, res) => { + const { name } = req.query + if (!name) { + res.send([]) + return + } + + const users = await userDatabase.find({ + where: { + name: Like(`%${name}%`), + }, + relations: { + community: { + parent: true, + }, + }, + select: { + id: true, + name: true, + yearOfBirth: true, + community: { + id: true, + name: true, + parent: { + id: true, + name: true, + }, + }, + }, + }) + res.send(users) +}) + router.get("/no-community-user-list", async (req, res) => { const userList = await userDatabase.find({ where: { diff --git a/server/src/routes/admin/dashboard.ts b/server/src/routes/admin/dashboard.ts index fae8891..ec740b2 100644 --- a/server/src/routes/admin/dashboard.ts +++ b/server/src/routes/admin/dashboard.ts @@ -2,12 +2,13 @@ import { Router } from "express" import { Request, Response } from "express" import { Between, In, Not, IsNull, LessThanOrEqual } from "typeorm" import { AttendData } from "../../entity/attendData" -import { AttendStatus } from "../../entity/types" +import { AttendStatus, EducationLecture } from "../../entity/types" import { userDatabase, communityDatabase, attendDataDatabase, worshipScheduleDatabase, + newcomerEducationDatabase, } from "../../model/dataSource" const router = Router() @@ -35,13 +36,6 @@ const getMonthEnd = (date: Date) => { return new Date(date.getFullYear(), date.getMonth() + 1, 0) } -// 새가족 판별 함수 (등반 후 6개월) -const isNewFamily = (createAt: Date) => { - const sixMonthsAgo = new Date() - sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6) - return new Date(createAt) >= sixMonthsAgo -} - // GET /admin/dashboard - 대시보드 데이터 조회 router.get("/", async (req: Request, res: Response) => { try { @@ -70,7 +64,7 @@ router.get("/", async (req: Request, res: Response) => { where: { date: Between( weekStart.toISOString().split("T")[0], - weekEnd.toISOString().split("T")[0] + weekEnd.toISOString().split("T")[0], ), }, }) @@ -80,7 +74,7 @@ router.get("/", async (req: Request, res: Response) => { where: { date: Between( monthStart.toISOString().split("T")[0], - monthEnd.toISOString().split("T")[0] + monthEnd.toISOString().split("T")[0], ), }, }) @@ -111,19 +105,30 @@ router.get("/", async (req: Request, res: Response) => { }) : [] + // 새가족 등록자 수 계산 함수 + const countNewFamilyRegistrants = async (scheduleIds: number[]) => { + if (scheduleIds.length === 0) return 0 + return await newcomerEducationDatabase.count({ + where: { + worshipSchedule: { id: In(scheduleIds) }, + lectureType: EducationLecture.OT, + }, + }) + } + // 통계 계산 함수 const calculateStats = (attendanceData: AttendData[]) => { // 유효한 사용자 데이터만 필터링 const validAttendanceData = attendanceData.filter((a) => a.user) const attendCount = validAttendanceData.filter( - (a) => a.isAttend === AttendStatus.ATTEND + (a) => a.isAttend === AttendStatus.ATTEND, ).length const absentCount = validAttendanceData.filter( - (a) => a.isAttend === AttendStatus.ABSENT + (a) => a.isAttend === AttendStatus.ABSENT, ).length const etcCount = validAttendanceData.filter( - (a) => a.isAttend === AttendStatus.ETC + (a) => a.isAttend === AttendStatus.ETC, ).length const total = validAttendanceData.length @@ -132,10 +137,10 @@ router.get("/", async (req: Request, res: Response) => { // 성비 계산 const maleCount = validAttendanceData.filter( - (a) => a.user.gender === "man" + (a) => a.user.gender === "man", ).length const femaleCount = validAttendanceData.filter( - (a) => a.user.gender === "woman" + (a) => a.user.gender === "woman", ).length const genderTotal = maleCount + femaleCount const malePercent = @@ -143,13 +148,6 @@ router.get("/", async (req: Request, res: Response) => { const femalePercent = genderTotal > 0 ? Math.round((femaleCount / genderTotal) * 100) : 0 - // 새가족 비율 - const newFamilyCount = validAttendanceData.filter((a) => - isNewFamily(a.user.createAt) - ).length - const newFamilyPercent = - total > 0 ? Math.round((newFamilyCount / total) * 100) : 0 - return { attendCount, absentCount, @@ -157,12 +155,24 @@ router.get("/", async (req: Request, res: Response) => { attendPercent, genderRatio: { male: malePercent, female: femalePercent }, genderCount: { male: maleCount, female: femaleCount }, - newFamilyPercent, } } - const weeklyStats = calculateStats(weeklyAttendance) - const monthlyStats = calculateStats(monthlyAttendance) + const weeklyRegistrants = await countNewFamilyRegistrants( + weeklySchedules.map((s) => s.id), + ) + const monthlyRegistrants = await countNewFamilyRegistrants( + monthlySchedules.map((s) => s.id), + ) + + const weeklyStats = { + ...calculateStats(weeklyAttendance), + newFamilyRegistrants: weeklyRegistrants, + } + const monthlyStats = { + ...calculateStats(monthlyAttendance), + newFamilyRegistrants: monthlyRegistrants, + } // 최근 4주간 예배 일정 조회 for Trend const last4WeeksSchedules = await worshipScheduleDatabase.find({ @@ -177,7 +187,7 @@ router.get("/", async (req: Request, res: Response) => { // Sort to chronological order last4WeeksSchedules.sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), ) const last4WeeksStats = await Promise.all( @@ -191,11 +201,15 @@ router.get("/", async (req: Request, res: Response) => { }, }) const stats = calculateStats(attendance) + const newFamilyRegistrants = await countNewFamilyRegistrants([ + schedule.id, + ]) return { date: schedule.date, ...stats, + newFamilyRegistrants, } - }) + }), ) // 최근 3주간 예배 일정 조회 (For Absentees - keeping existing logic) @@ -203,7 +217,7 @@ router.get("/", async (req: Request, res: Response) => { where: { date: Between( threeWeeksAgo.toISOString().split("T")[0], - now.toISOString().split("T")[0] + now.toISOString().split("T")[0], ), }, order: { @@ -282,7 +296,7 @@ router.get("/stats", async (req: Request, res: Response) => { where: { date: Between( weekStart.toISOString().split("T")[0], - weekEnd.toISOString().split("T")[0] + weekEnd.toISOString().split("T")[0], ), }, }) @@ -300,7 +314,7 @@ router.get("/stats", async (req: Request, res: Response) => { : [] const attendCount = weeklyAttendance.filter( - (a) => a.isAttend === AttendStatus.ATTEND + (a) => a.isAttend === AttendStatus.ATTEND, ).length const totalCount = weeklyAttendance.length const attendanceRate = @@ -314,7 +328,7 @@ router.get("/stats", async (req: Request, res: Response) => { where: { date: Between( oneMonthAgo.toISOString().split("T")[0], - now.toISOString().split("T")[0] + now.toISOString().split("T")[0], ), }, }) diff --git a/server/src/routes/authRouter.ts b/server/src/routes/authRouter.ts index d979c05..86a4ded 100644 --- a/server/src/routes/authRouter.ts +++ b/server/src/routes/authRouter.ts @@ -3,6 +3,10 @@ import { getUserFromToken } from "../util/util" import { communityDatabase } from "../model/dataSource" import { User } from "../entity/user" import userModel from "../model/user" +import { + getKakaoIdFromAccessToken, + getKakaoTokenFromAuthCode, +} from "../util/auth" const router = express.Router() @@ -27,11 +31,6 @@ router.post("/edit-my-information", async (req, res) => { res.send({ result: "success" }) }) -router.get("/community", async (req, res) => { - const communityList = await communityDatabase.find() - res.send(communityList) -}) - router.post("/receipt-record", async (req, res) => { const body = req.body @@ -52,9 +51,13 @@ router.post("/receipt-record", async (req, res) => { const twentyOneDays = 1000 * 60 * 60 * 24 * 21 router.post("/login", async (req, res) => { - const body = req.body + const { kakaoToken } = req.body - const kakaoId = body.kakaoId + const kakaoId = await getKakaoIdFromAccessToken(kakaoToken as string) + if (!kakaoId) { + res.status(404).send({ result: "fail" }) + return + } const newUserToken = await userModel.loginFromKakaoId(kakaoId) if (!newUserToken) { @@ -74,6 +77,18 @@ router.post("/login", async (req, res) => { .send({ result: "success", accessToken: newUserToken.accessToken }) }) +router.post("/get-kakao-token", async (req, res) => { + const { code } = req.body + + const kakaoToken = await getKakaoTokenFromAuthCode(code as string) + if (!kakaoToken) { + res.status(404).send({ result: "fail" }) + return + } + + res.send({ result: "success", kakaoToken: kakaoToken }) +}) + router.post("/refresh-token", async (req, res) => { const refreshToken = req.cookies.refreshToken if (!refreshToken) { diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 2d65ae7..521a28a 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -7,6 +7,7 @@ import adminRouter from "./admin/adminRouter" import retreatRouter from "./retreat/retreatRouter" import inOutInfoRouter from "./retreat/inOutInfoRouter" import soonRouter from "./soon/soonRouter" +import newcomerRouter from "./newcomer/newcomerRouter" import eventRouter from "./event" const router: Router = express.Router() @@ -19,6 +20,7 @@ router.use("/retreat", retreatRouter) router.use("/in-out-info", inOutInfoRouter) router.use("/soon", soonRouter) +router.use("/newcomer", newcomerRouter) router.use("/event", eventRouter) diff --git a/server/src/routes/newcomer/newcomerRouter.ts b/server/src/routes/newcomer/newcomerRouter.ts new file mode 100644 index 0000000..00ac19f --- /dev/null +++ b/server/src/routes/newcomer/newcomerRouter.ts @@ -0,0 +1,601 @@ +import express from "express" + +import { + communityDatabase, + newcomerDatabase, + newcomerEducationDatabase, + newcomerManagerDatabase, + userDatabase, + worshipScheduleDatabase, +} from "../../model/dataSource" +import { checkJwt } from "../../util/util" +import { EducationLecture, NewcomerStatus } from "../../entity/types" + +const router = express.Router() + +// 현재 유저에 해당하는 NewcomerManager를 찾거나 생성 +async function getOrCreateNewcomerManager(user: any) { + let manager = await newcomerManagerDatabase.findOne({ + where: { user: { id: user.id } }, + }) + + if (!manager) { + manager = newcomerManagerDatabase.create({ user }) + await newcomerManagerDatabase.save(manager) + } + + return manager +} + +// 1. 새신자 등록 +router.post("/", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const { + name, + yearOfBirth, + gender, + phone, + guiderId, + assignmentId, + newcomerManagerId, + } = req.body + + if (!name) { + res.status(400).send({ error: "이름은 필수입니다." }) + return + } + + try { + // 인도자 확인 + let guider = null + if (guiderId) { + guider = await userDatabase.findOne({ where: { id: guiderId } }) + } + + // NewcomerManager 확인 또는 현재 유저로 생성 + let newcomerManager = null + if (newcomerManagerId) { + newcomerManager = await newcomerManagerDatabase.findOne({ + where: { id: newcomerManagerId }, + }) + } + if (!newcomerManager) { + // 지정된 담당자가 없으면 현재 유저로 자동 생성 + newcomerManager = await getOrCreateNewcomerManager(user) + } + + // 배정(Community) 확인 + let assignment = null + if (assignmentId) { + assignment = await communityDatabase.findOne({ + where: { id: assignmentId }, + }) + } + + const newcomer = newcomerDatabase.create({ + name, + yearOfBirth: yearOfBirth ? parseInt(yearOfBirth, 10) : null, + gender: gender || null, + phone: phone?.replace(/[^\d]/g, "") || null, + guider, + assignment, + newcomerManager, + status: NewcomerStatus.NORMAL, + }) + + await newcomerDatabase.save(newcomer) + res.status(201).send(newcomer) + } catch (error) { + console.error("Error creating newcomer:", error) + res.status(500).send({ error: "새신자 등록에 실패했습니다." }) + } +}) + +router.put("/:id", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const { id } = req.params + const { + name, + yearOfBirth, + gender, + phone, + guiderId, + assignmentId, + newcomerManagerId, + status, + } = req.body + + try { + const newcomer = await newcomerDatabase.findOne({ where: { id } }) + if (!newcomer) { + res.status(404).send({ error: "새신자를 찾을 수 없습니다." }) + return + } + + if (name) newcomer.name = name + if (yearOfBirth !== undefined) + newcomer.yearOfBirth = yearOfBirth ? parseInt(yearOfBirth, 10) : null + if (gender !== undefined) newcomer.gender = gender || null + if (phone !== undefined) + newcomer.phone = phone?.replace(/[^\d]/g, "") || null + if (status) newcomer.status = status + + // 인도자 업데이트 + if (guiderId !== undefined) { + if (guiderId) { + const guider = await userDatabase.findOne({ where: { id: guiderId } }) + newcomer.guider = guider + } else { + newcomer.guider = null + } + } + + // NewcomerManager 업데이트 + if (newcomerManagerId !== undefined) { + if (newcomerManagerId) { + const manager = await newcomerManagerDatabase.findOne({ + where: { id: newcomerManagerId }, + }) + newcomer.newcomerManager = manager + } else { + newcomer.newcomerManager = null + } + } + + // 배정 업데이트 + if (assignmentId !== undefined) { + if (assignmentId) { + const assignment = await communityDatabase.findOne({ + where: { id: assignmentId }, + }) + newcomer.assignment = assignment + } else { + newcomer.assignment = null + } + } + + await newcomerDatabase.save(newcomer) + res.status(200).send(newcomer) + } catch (error) { + console.error("Error updating newcomer:", error) + res.status(500).send({ error: "새신자 정보 수정에 실패했습니다." }) + } +}) + +// 1-2. 새신자 삭제 +router.delete("/:id", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const { id } = req.params + + try { + const result = await newcomerDatabase.delete(id) + if (result.affected === 0) { + res.status(404).send({ error: "새신자를 찾을 수 없습니다." }) + return + } + res.status(200).send({ success: true }) + } catch (error) { + console.error("Error deleting newcomer:", error) + res.status(500).send({ error: "새신자 삭제에 실패했습니다." }) + } +}) + +// 2. 새신자 조회 (리스트) +router.get("/", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + try { + const status = req.query.status as NewcomerStatus | undefined + + const whereCondition: any = {} + if (status) { + whereCondition.status = status + } + + const newcomers = await newcomerDatabase.find({ + where: whereCondition, + relations: { + guider: true, + assignment: true, + newcomerManager: { + user: true, + }, + educationRecords: { + worshipSchedule: true, + }, + }, + order: { + createdAt: "DESC", + }, + }) + + // 민감한 정보 제거 + const sanitizedNewcomers = newcomers.map((newcomer) => ({ + ...newcomer, + guider: newcomer.guider + ? { id: newcomer.guider.id, name: newcomer.guider.name } + : null, + assignment: newcomer.assignment + ? { id: newcomer.assignment.id, name: newcomer.assignment.name } + : null, + newcomerManager: newcomer.newcomerManager?.user + ? { + id: newcomer.newcomerManager.id, + user: { + id: newcomer.newcomerManager.user.id, + name: newcomer.newcomerManager.user.name, + }, + } + : null, + })) + + res.status(200).send(sanitizedNewcomers) + } catch (error: any) { + console.error("Error fetching newcomers:", error.message, error.stack) + res.status(500).send({ + error: "새신자 목록 조회에 실패했습니다.", + detail: error.message, + }) + } +}) + +// 3. 새신자 교육 출석 조회 (날짜별 테이블 형식 - 출석 테이블과 동일한 구조) +router.get("/education", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + try { + const status = req.query.status as NewcomerStatus | undefined + + const whereCondition: any = {} + if (status) { + whereCondition.status = status + } + + // 새신자 조회 (교육 기록 포함) + const newcomers = await newcomerDatabase.find({ + where: whereCondition, + relations: { + guider: true, + assignment: true, + newcomerManager: { + user: true, + }, + educationRecords: { + worshipSchedule: true, + }, + }, + order: { + createdAt: "DESC", + }, + }) + + // 최근 8주간의 예배 스케줄 조회 + const eightWeeksAgo = new Date() + eightWeeksAgo.setDate(eightWeeksAgo.getDate() - 56) + const eightWeeksAgoStr = eightWeeksAgo.toISOString().split("T")[0] + + const recentSchedules = await worshipScheduleDatabase + .createQueryBuilder("schedule") + .where("schedule.date >= :startDate", { startDate: eightWeeksAgoStr }) + .orderBy("schedule.date", "DESC") + .getMany() + + // 날짜별 스케줄 맵 생성 + const worshipScheduleMap: Record = {} + const sortedDates: string[] = [] + + recentSchedules.forEach((schedule) => { + if (!worshipScheduleMap[schedule.date]) { + worshipScheduleMap[schedule.date] = { + id: schedule.id, + date: schedule.date, + } + sortedDates.push(schedule.date) + } + }) + + // 테이블 형식으로 변환: 각 새신자별로 날짜 → 강의 타입 매핑 + const educationTable = newcomers.map((newcomer) => { + // 날짜별 교육 기록 맵핑 + const educationByDate: Record = {} + + newcomer.educationRecords?.forEach((record) => { + if (record.worshipSchedule?.date) { + educationByDate[record.worshipSchedule.date] = { + id: record.id, + lectureType: record.lectureType, + worshipScheduleId: record.worshipSchedule.id, + memo: record.memo, + } + } + }) + + return { + id: newcomer.id, + name: newcomer.name, + yearOfBirth: newcomer.yearOfBirth, + gender: newcomer.gender, + phone: newcomer.phone, + status: newcomer.status, + guider: newcomer.guider + ? { id: newcomer.guider.id, name: newcomer.guider.name } + : null, + newcomerManager: newcomer.newcomerManager?.user + ? { + id: newcomer.newcomerManager.id, + user: { + id: newcomer.newcomerManager.user.id, + name: newcomer.newcomerManager.user.name, + }, + } + : null, + assignment: newcomer.assignment + ? { id: newcomer.assignment.id, name: newcomer.assignment.name } + : null, + createdAt: newcomer.createdAt, + education: educationByDate, // { "2026-01-11": { lectureType: "OT" }, ... } + } + }) + + res.status(200).send({ + dates: sortedDates, // 테이블 헤더용 날짜 목록 + worshipSchedules: sortedDates.map((date) => worshipScheduleMap[date]), + newcomers: educationTable, + }) + } catch (error) { + console.error("Error fetching newcomers education:", error) + res.status(500).send({ error: "교육 출석 조회에 실패했습니다." }) + } +}) + +// 4. 새신자 교육 등록/업데이트 +router.put("/:id/education", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const newcomerId = req.params.id + const { lectureType, worshipScheduleId, memo } = req.body + + try { + const newcomer = await newcomerDatabase.findOne({ + where: { id: newcomerId }, + }) + + if (!newcomer) { + res.status(404).send({ error: "새신자를 찾을 수 없습니다." }) + return + } + + // lectureType이 null이거나 빈 문자열이면 해당 스케줄의 기록 삭제 + if (!lectureType) { + if (worshipScheduleId) { + await newcomerEducationDatabase.delete({ + newcomer: { id: newcomerId }, + worshipSchedule: { id: worshipScheduleId }, + }) + } + res.status(200).send({ success: true, deleted: true }) + return + } + + // lectureType 유효성 검사 + if (!Object.values(EducationLecture).includes(lectureType)) { + res.status(400).send({ error: "유효하지 않은 강의 타입입니다." }) + return + } + + // 해당 강의 타입의 기존 기록 확인 + let educationRecord = await newcomerEducationDatabase.findOne({ + where: { + newcomer: { id: newcomerId }, + lectureType: lectureType, + }, + }) + + let worshipSchedule = null + if (worshipScheduleId) { + worshipSchedule = await worshipScheduleDatabase.findOne({ + where: { id: worshipScheduleId }, + }) + } + + if (educationRecord) { + // 기존 기록 업데이트 + educationRecord.worshipSchedule = worshipSchedule + educationRecord.memo = memo || null + await newcomerEducationDatabase.save(educationRecord) + } else { + // 새 기록 생성 + educationRecord = newcomerEducationDatabase.create({ + newcomer, + lectureType, + worshipSchedule, + memo: memo || null, + }) + await newcomerEducationDatabase.save(educationRecord) + } + + res.status(200).send(educationRecord) + } catch (error) { + console.error("Error updating newcomer education:", error) + res.status(500).send({ error: "교육 정보 업데이트에 실패했습니다." }) + } +}) + +// 7. 사용자 목록 조회 (담당자 선택용) +router.get("/users", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + try { + const users = await userDatabase.find({ + select: ["id", "name", "yearOfBirth", "gender"], + order: { name: "ASC" }, + }) + res.status(200).send(users) + } catch (error) { + console.error("Error fetching users:", error) + res.status(500).send({ error: "사용자 목록 조회에 실패했습니다." }) + } +}) + +// 8. 담당자 목록 조회 +router.get("/managers", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + try { + const managers = await newcomerManagerDatabase.find({ + relations: { + user: true, + newcomers: true, + }, + order: { + user: { name: "ASC" }, + }, + }) + + const sanitizedManagers = managers.map((manager) => ({ + id: manager.id, + user: { + id: manager.user.id, + name: manager.user.name, + yearOfBirth: manager.user.yearOfBirth, + gender: manager.user.gender, + }, + newcomers: + manager.newcomers?.map((newcomer) => ({ + id: newcomer.id, + name: newcomer.name, + yearOfBirth: newcomer.yearOfBirth, + })) || [], + })) + + res.status(200).send(sanitizedManagers) + } catch (error) { + console.error("Error fetching managers:", error) + res.status(500).send({ error: "담당자 목록 조회에 실패했습니다." }) + } +}) + +// 9. 담당자 등록 (User -> NewcomerManager) +router.post("/managers", async (req, res) => { + const authUser = await checkJwt(req) + if (!authUser) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const { userId } = req.body + + if (!userId) { + res.status(400).send({ error: "userId는 필수입니다." }) + return + } + + try { + // 이미 담당자인지 확인 + const existingManager = await newcomerManagerDatabase.findOne({ + where: { user: { id: userId } }, + }) + + if (existingManager) { + res.status(400).send({ error: "이미 담당자로 등록되어 있습니다." }) + return + } + + // 사용자 확인 + const targetUser = await userDatabase.findOne({ where: { id: userId } }) + if (!targetUser) { + res.status(404).send({ error: "사용자를 찾을 수 없습니다." }) + return + } + + // 담당자 등록 + const manager = newcomerManagerDatabase.create({ user: targetUser }) + await newcomerManagerDatabase.save(manager) + + res.status(201).send({ + id: manager.id, + user: { + id: targetUser.id, + name: targetUser.name, + }, + newcomers: [], + }) + } catch (error) { + console.error("Error creating manager:", error) + res.status(500).send({ error: "담당자 등록에 실패했습니다." }) + } +}) + +// 10. 담당자 해제 +router.delete("/managers/:id", async (req, res) => { + const user = await checkJwt(req) + if (!user) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const { id } = req.params + + try { + const manager = await newcomerManagerDatabase.findOne({ + where: { id }, + relations: { newcomers: true }, + }) + + if (!manager) { + res.status(404).send({ error: "담당자를 찾을 수 없습니다." }) + return + } + + // 담당 새신자가 있으면 연결 해제 + if (manager.newcomers && manager.newcomers.length > 0) { + for (const newcomer of manager.newcomers) { + newcomer.newcomerManager = null as any + await newcomerDatabase.save(newcomer) + } + } + + await newcomerManagerDatabase.delete(id) + res.status(200).send({ success: true }) + } catch (error) { + console.error("Error deleting manager:", error) + res.status(500).send({ error: "담당자 해제에 실패했습니다." }) + } +}) + +export default router diff --git a/server/src/routes/soon/soonRouter.ts b/server/src/routes/soon/soonRouter.ts index 81de32d..17be4a0 100644 --- a/server/src/routes/soon/soonRouter.ts +++ b/server/src/routes/soon/soonRouter.ts @@ -10,6 +10,7 @@ import { checkJwt, getUserFromToken } from "../../util/util" import { Community } from "../../entity/community" import { User } from "../../entity/user" import { In } from "typeorm" +import { getKakaoIdFromAccessToken } from "../../util/auth" const router = express.Router() @@ -23,7 +24,7 @@ async function getAllSoonUsers(community: Community) { const childUsersPromise = await communityWithRelations.children.map( async (childCommunity) => { return await getAllSoonUsers(childCommunity) - } + }, ) const awaitedChildUsers = (await Promise.all(childUsersPromise)).flat() @@ -51,7 +52,7 @@ router.get("/my-group-info", async (req, res) => { phone: user.phone, gender: user.gender, kakaoId: !!user.kakaoId, - } as any) + }) as any, ) res.send(group) @@ -208,7 +209,9 @@ router.post("/attendance", async (req, res) => { }) router.post("/register-kakao-login", async (req, res) => { - const { userId, kakaoId } = req.body + const { userId, kakaoToken } = req.body + + const kakaoId = await getKakaoIdFromAccessToken(kakaoToken) const existingUsers = await userDatabase.findOne({ where: { @@ -280,7 +283,6 @@ router.get("/my-info", async (req, res) => { router.get("/existing-users", async (req, res) => { const user = await checkJwt(req) - console.log("user", user) if (!user) { res.status(401).send({ error: "Unauthorized" }) return @@ -348,6 +350,7 @@ router.get("/retreat-attendance-records", async (req, res) => { isWorker: true, isHalf: true, createAt: true, + isCanceled: true, }, }, relations: { @@ -355,15 +358,6 @@ router.get("/retreat-attendance-records", async (req, res) => { }, }) - attendDataList = attendDataList.filter((soon) => { - if (!soon.retreatAttend) { - return true - } - if (soon.retreatAttend.isCanceled) { - return false - } - return true - }) res.status(200).send(attendDataList) }) diff --git a/server/src/util/auth.ts b/server/src/util/auth.ts index bf7a056..9f71b67 100644 --- a/server/src/util/auth.ts +++ b/server/src/util/auth.ts @@ -1,24 +1,8 @@ import jwt from "jsonwebtoken" import { User } from "../entity/user" import { REFRESH_TOKEN_EXPIRE_DAYS } from "../model/user" -import { Community } from "../entity/community" -import { communityDatabase } from "../model/dataSource" - -export interface Role { - Admin: boolean - Leader: boolean - VillageLeader: boolean -} - -export interface jwtPayload { - id: string - name: string - yearOfBirth: number - community: Community - role: Role - iat: number - exp: number -} +import { communityDatabase, newcomerManagerDatabase } from "../model/dataSource" +import { Role } from "./type" export function generateRefreshToken(user: User) { const payload = { @@ -72,9 +56,65 @@ async function getRole(user: User): Promise { villageLeader = true } + const newcomerManager = await newcomerManagerDatabase.findOne({ + where: { user: { id: user.id } }, + }) + return { Admin: user.isSuperUser, Leader: isLeader, VillageLeader: villageLeader, + NewcomerManager: newcomerManager ? true : false, + } +} + +export async function getKakaoTokenFromAuthCode(code: string): Promise { + const response = await fetch("https://kauth.kakao.com/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: process.env.KAKAO_REST_API_KEY || "", + redirect_uri: `${getServerUrl()}/common/login`, + code: code, + }), + }) + + const tokenData = (await response.json()) as { + access_token: string + refresh_token: string + expires_in: number + refresh_token_expires_in: number + scope: string + } + return tokenData.access_token +} + +export async function getKakaoIdFromAccessToken( + accessToken: string, +): Promise { + const userResponse = await fetch("https://kapi.kakao.com/v2/user/me", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + const userData = (await userResponse.json()) as { id: string } + return userData.id +} + +//Todo: cors에 있는 것도 그렇고, 어떻게 관리 해야 하나? +const target = process.env.NEXT_PUBLIC_API_TARGET +function getServerUrl() { + switch (target) { + case "prod": + return `https://nuon.iubns.net` + case "dev": + return `https://nuon-dev.iubns.net` + case "local": + default: + return `http://localhost:8080` } } diff --git a/server/src/util/type.ts b/server/src/util/type.ts new file mode 100644 index 0000000..5de8acf --- /dev/null +++ b/server/src/util/type.ts @@ -0,0 +1,18 @@ +import { Community } from "../entity/community" + +export interface Role { + Admin: boolean + Leader: boolean + VillageLeader: boolean + NewcomerManager: boolean +} + +export interface jwtPayload { + id: string + name: string + yearOfBirth: number + community: Community + role: Role + iat: number + exp: number +} diff --git a/server/src/util/util.ts b/server/src/util/util.ts index daead33..4193be3 100644 --- a/server/src/util/util.ts +++ b/server/src/util/util.ts @@ -5,7 +5,7 @@ import { permissionDatabase, userDatabase } from "../model/dataSource" import { User } from "../entity/user" import express from "express" import jwt from "jsonwebtoken" -import { jwtPayload } from "./auth" +import { jwtPayload } from "./type" const env = dotenv.config().parsed || {} @@ -18,7 +18,7 @@ export const hashCode = function (content: string) { export async function hasPermission( token: string | undefined, - permissionType: PermissionType + permissionType: PermissionType, ): Promise { const payload = jwt.verify(token, env.JWT_SECRET) as jwtPayload @@ -48,7 +48,7 @@ export async function hasPermission( } const userListPermission = foundUser.permissions.find( - (permission) => permission.permissionType === permissionType + (permission) => permission.permissionType === permissionType, ) if (userListPermission && userListPermission.have) { @@ -102,7 +102,7 @@ export async function checkJwt(req: express.Request) { export async function hasPermissionFromReq( req: express.Request, - permissionType: PermissionType + permissionType: PermissionType, ) { const token = req.header("token") return await hasPermission(token, permissionType)