From 1facf23c686dc1d85a24ad867ca03906510937d8 Mon Sep 17 00:00:00 2001 From: iubns Date: Thu, 22 Jan 2026 01:01:52 +0900 Subject: [PATCH 01/21] update: kakao Login v2 --- .DS_Store | Bin 8196 -> 8196 bytes client/src/app/common/login/page.tsx | 3 +- client/src/app/layout.tsx | 11 +++--- client/src/config/axios.ts | 22 ++++++------ client/src/hooks/useKakao.ts | 25 ++++++++----- server/src/routes/authRouter.ts | 26 +++++++++----- server/src/util/auth.ts | 51 +++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 34 deletions(-) diff --git a/.DS_Store b/.DS_Store index 956d1892b564fb69d37c8dfd7196249261beac42..c4a20ff032ce3613172aa29bb3972d50a32dd34d 100644 GIT binary patch delta 119 zcmZp1XmQvOEhx4lsURn_xWvHV8Y2@k3o9Et2Rl1A`{Y8wNH30f0g38rV@p#V1ru}g zS{;RIOCtjy+t{qOmV-l7S>HM+K07BjFTaZc3>X<9Gy^Y`hEd&{g@rtrHphsp;06GH ClozQ0 delta 120 zcmZp1XmQvOEhx4 - {isKakaoBrowser ? (
카카오톡 브라우저에서는 사용할 수 없습니다.
diff --git a/client/src/config/axios.ts b/client/src/config/axios.ts index 345fc8a..b5c56f0 100644 --- a/client/src/config/axios.ts +++ b/client/src/config/axios.ts @@ -1,24 +1,26 @@ import axios from "axios" -let PORT = 8000 -const getBaseUrl = () => { +export const GetServerUrl = () => { const target = process.env.NEXT_PUBLIC_API_TARGET + let PORT = 8000 switch (target) { case "prod": - return process.env.NEXT_PUBLIC_PROD_SERVER + PORT = 8000 + return `${process.env.NEXT_PUBLIC_PROD_SERVER}:${PORT}` case "dev": PORT = 8001 - return process.env.NEXT_PUBLIC_DEV_SERVER + return `${process.env.NEXT_PUBLIC_DEV_SERVER}:${PORT}` case "local": default: - return process.env.NEXT_PUBLIC_LOCAL_SERVER + PORT = 8000 + return `${process.env.NEXT_PUBLIC_LOCAL_SERVER}:${PORT}` } } -const SERVER_URL = getBaseUrl() +const SERVER_URL = GetServerUrl() -export const SERVER_FULL_PATH = `${SERVER_URL}:${PORT}` +export const SERVER_FULL_PATH = `${SERVER_URL}` const isBrowser = typeof window !== "undefined" @@ -35,7 +37,7 @@ axios.interceptors.request.use( }, (error) => { return Promise.reject(error) - } + }, ) axios.interceptors.response.use( @@ -46,11 +48,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/useKakao.ts b/client/src/hooks/useKakao.ts index af255dd..99dea12 100644 --- a/client/src/hooks/useKakao.ts +++ b/client/src/hooks/useKakao.ts @@ -1,3 +1,4 @@ +import { GetServerUrl } from "@/config/axios" import { useEffect } from "react" export default function useKakaoHook() { @@ -37,7 +38,13 @@ export default function useKakaoHook() { return false } - function getKakaoToken(): Promise { + const SERVER_URL = GetServerUrl() + + async function getKakaoToken(): Promise { + const r = await Kakao.Auth.authorize({ + redirectUri: `${SERVER_URL}/auth/login`, + }) + console.log(r) return new Promise((resolve, reject) => { if (!Kakao) { alert("카카오 SDK가 로딩되지 않았습니다.\n잠시후 다시 눌러주세요.") @@ -46,22 +53,22 @@ export default function useKakaoHook() { } return } - Kakao.Auth.login({ - success: function (response: Response) { + /** + * success: function (response: Response) { Kakao.API.request({ url: "/v2/user/me", - success: function (response: { id: number }) { + }) + .then(function (response: { id: number }) { resolve(response.id) - }, - fail: function (error: any) { + }) + .catch(function (error: any) { reject(error) - }, - }) + }) }, fail: function (error: any) { reject(error) }, - }) + */ }) } diff --git a/server/src/routes/authRouter.ts b/server/src/routes/authRouter.ts index d979c05..db99a44 100644 --- a/server/src/routes/authRouter.ts +++ b/server/src/routes/authRouter.ts @@ -3,6 +3,7 @@ import { getUserFromToken } from "../util/util" import { communityDatabase } from "../model/dataSource" import { User } from "../entity/user" import userModel from "../model/user" +import { getKakaoIdFromAuthCode } from "../util/auth" const router = express.Router() @@ -27,11 +28,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 @@ -51,10 +47,14 @@ router.post("/receipt-record", async (req, res) => { }) const twentyOneDays = 1000 * 60 * 60 * 24 * 21 -router.post("/login", async (req, res) => { - const body = req.body +router.get("/login", async (req, res) => { + const { code } = req.query - const kakaoId = body.kakaoId + const kakaoId = await getKakaoIdFromAuthCode(code as string) + if (!kakaoId) { + res.status(404).send({ result: "fail" }) + return + } const newUserToken = await userModel.loginFromKakaoId(kakaoId) if (!newUserToken) { @@ -71,7 +71,15 @@ router.post("/login", async (req, res) => { maxAge: twentyOneDays, expires: new Date(Date.now() + twentyOneDays), }) - .send({ result: "success", accessToken: newUserToken.accessToken }) + .cookie("accessToken", newUserToken.accessToken, { + httpOnly: false, + sameSite: "none", + secure: true, + maxAge: twentyOneDays, + expires: new Date(Date.now() + twentyOneDays), + }) + .redirect(`history.back()`) + //.send({ result: "success", accessToken: newUserToken.accessToken }) }) router.post("/refresh-token", async (req, res) => { diff --git a/server/src/util/auth.ts b/server/src/util/auth.ts index bf7a056..f4cd9d2 100644 --- a/server/src/util/auth.ts +++ b/server/src/util/auth.ts @@ -78,3 +78,54 @@ async function getRole(user: User): Promise { VillageLeader: villageLeader, } } + +export async function getKakaoIdFromAuthCode(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()}/auth/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 + } + // 만약, 카카오의 다른 API를 사용하고 싶다면 access token을 DB에 저장해야 함 + const accessToken = tokenData.access_token + + 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 +} + +const target = process.env.NEXT_PUBLIC_API_TARGET +function getServerUrl() { + let PORT = 8000 + switch (target) { + case "prod": + PORT = 8000 + return `https://nuon.iubns.net:${PORT}` + case "dev": + PORT = 8001 + return `https://nuon-dev.iubns.net:${PORT}` + case "local": + default: + PORT = 8000 + return `http://localhost:${PORT}` + } +} From 7145cf2cc6beb6daedb50bf1fd70e91cf052af4e Mon Sep 17 00:00:00 2001 From: iubns Date: Thu, 22 Jan 2026 04:16:44 +0900 Subject: [PATCH 02/21] update: kakao login v1 to v2.7 --- .../src/app/common/kakaoUserConnect/page.tsx | 43 +++++++++++-------- client/src/app/common/login/page.tsx | 27 ++++++++---- client/src/app/retreat/hooks/useRetreat.ts | 4 +- client/src/app/retreat/login/page.tsx | 7 ++- client/src/app/retreat/steps/first.tsx | 7 +-- client/src/hooks/useAuth.ts | 24 +++++++---- client/src/hooks/useKakao.ts | 38 +++------------- server/src/routes/authRouter.ts | 33 ++++++++------ server/src/routes/soon/soonRouter.ts | 9 ++-- server/src/util/auth.ts | 20 +++++---- 10 files changed, 113 insertions(+), 99 deletions(-) diff --git a/client/src/app/common/kakaoUserConnect/page.tsx b/client/src/app/common/kakaoUserConnect/page.tsx index 3a12efd..1f30abe 100644 --- a/client/src/app/common/kakaoUserConnect/page.tsx +++ b/client/src/app/common/kakaoUserConnect/page.tsx @@ -1,6 +1,7 @@ "use client" -import { post, get } from "@/config/api" +import axios from "@/config/axios" +import useAuth from "@/hooks/useAuth" import useKakaoHook from "@/hooks/useKakao" import { Button, Stack } from "@mui/material" import { useSearchParams } from "next/navigation" @@ -17,49 +18,57 @@ export default function KakaoLoginPage() { function KakaoLogin() { const searchParams = useSearchParams() const userId = searchParams.get("userId") + const { executeKakaoLogin } = useKakaoHook() + const { kakaoToken } = useAuth() useEffect(() => { 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 2923975..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,22 +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() + const returnUrl = searchParams.get("returnUrl") || "/" + await executeKakaoLogin(returnUrl) } catch (error) { - console.error(error) - setNotificationMessage("등록되지 않은 사용자 입니다.") + setNotificationMessage("카카오 로그인 실패") } } 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/hooks/useAuth.ts b/client/src/hooks/useAuth.ts index a359696..ebbe1d6 100644 --- a/client/src/hooks/useAuth.ts +++ b/client/src/hooks/useAuth.ts @@ -29,11 +29,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 +68,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 +152,6 @@ export default function useAuth() { isAdminIfNotExit, logout, kakaoToken, + getKakaoTokenFromAuthCode, } } diff --git a/client/src/hooks/useKakao.ts b/client/src/hooks/useKakao.ts index 99dea12..5aaa384 100644 --- a/client/src/hooks/useKakao.ts +++ b/client/src/hooks/useKakao.ts @@ -38,41 +38,15 @@ export default function useKakaoHook() { return false } - const SERVER_URL = GetServerUrl() - - async function getKakaoToken(): Promise { - const r = await Kakao.Auth.authorize({ - redirectUri: `${SERVER_URL}/auth/login`, - }) - console.log(r) - return new Promise((resolve, reject) => { - if (!Kakao) { - alert("카카오 SDK가 로딩되지 않았습니다.\n잠시후 다시 눌러주세요.") - if (globalValue.Kakao) { - alert("globalValue.Kakao는 불러와짐") - } - return - } - /** - * success: function (response: Response) { - Kakao.API.request({ - url: "/v2/user/me", - }) - .then(function (response: { id: number }) { - resolve(response.id) - }) - .catch(function (error: any) { - reject(error) - }) - }, - fail: function (error: any) { - reject(error) - }, - */ + async function executeKakaoLogin(redirectUri: string = "") { + await Kakao.Auth.authorize({ + redirectUri: `http://localhost:8080/common/login`, + state: redirectUri, }) + return } return { - getKakaoToken, + executeKakaoLogin, } } diff --git a/server/src/routes/authRouter.ts b/server/src/routes/authRouter.ts index db99a44..86a4ded 100644 --- a/server/src/routes/authRouter.ts +++ b/server/src/routes/authRouter.ts @@ -3,7 +3,10 @@ import { getUserFromToken } from "../util/util" import { communityDatabase } from "../model/dataSource" import { User } from "../entity/user" import userModel from "../model/user" -import { getKakaoIdFromAuthCode } from "../util/auth" +import { + getKakaoIdFromAccessToken, + getKakaoTokenFromAuthCode, +} from "../util/auth" const router = express.Router() @@ -47,10 +50,10 @@ router.post("/receipt-record", async (req, res) => { }) const twentyOneDays = 1000 * 60 * 60 * 24 * 21 -router.get("/login", async (req, res) => { - const { code } = req.query +router.post("/login", async (req, res) => { + const { kakaoToken } = req.body - const kakaoId = await getKakaoIdFromAuthCode(code as string) + const kakaoId = await getKakaoIdFromAccessToken(kakaoToken as string) if (!kakaoId) { res.status(404).send({ result: "fail" }) return @@ -71,15 +74,19 @@ router.get("/login", async (req, res) => { maxAge: twentyOneDays, expires: new Date(Date.now() + twentyOneDays), }) - .cookie("accessToken", newUserToken.accessToken, { - httpOnly: false, - sameSite: "none", - secure: true, - maxAge: twentyOneDays, - expires: new Date(Date.now() + twentyOneDays), - }) - .redirect(`history.back()`) - //.send({ result: "success", accessToken: newUserToken.accessToken }) + .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) => { diff --git a/server/src/routes/soon/soonRouter.ts b/server/src/routes/soon/soonRouter.ts index 81de32d..2cc9b08 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: { diff --git a/server/src/util/auth.ts b/server/src/util/auth.ts index f4cd9d2..fa367f0 100644 --- a/server/src/util/auth.ts +++ b/server/src/util/auth.ts @@ -79,7 +79,7 @@ async function getRole(user: User): Promise { } } -export async function getKakaoIdFromAuthCode(code: string): Promise { +export async function getKakaoTokenFromAuthCode(code: string): Promise { const response = await fetch("https://kauth.kakao.com/oauth/token", { method: "POST", headers: { @@ -88,7 +88,7 @@ export async function getKakaoIdFromAuthCode(code: string): Promise { body: new URLSearchParams({ grant_type: "authorization_code", client_id: process.env.KAKAO_REST_API_KEY || "", - redirect_uri: `${getServerUrl()}/auth/login`, + redirect_uri: `${getServerUrl()}/common/login`, code: code, }), }) @@ -100,9 +100,12 @@ export async function getKakaoIdFromAuthCode(code: string): Promise { refresh_token_expires_in: number scope: string } - // 만약, 카카오의 다른 API를 사용하고 싶다면 access token을 DB에 저장해야 함 - const accessToken = tokenData.access_token + 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: { @@ -113,19 +116,18 @@ export async function getKakaoIdFromAuthCode(code: string): Promise { return userData.id } +//Todo: cors에 있는 것도 그렇고, 어떻게 관리 해야 하나? const target = process.env.NEXT_PUBLIC_API_TARGET function getServerUrl() { let PORT = 8000 switch (target) { case "prod": - PORT = 8000 - return `https://nuon.iubns.net:${PORT}` + return `https://nuon.iubns.net` case "dev": - PORT = 8001 - return `https://nuon-dev.iubns.net:${PORT}` + return `https://nuon-dev.iubns.net` case "local": default: - PORT = 8000 + PORT = 8080 return `http://localhost:${PORT}` } } From 530d4023a340ed5ce317e528a86d2e11fbbe893d Mon Sep 17 00:00:00 2001 From: Gardener-Soul Date: Sat, 24 Jan 2026 22:59:30 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/darak/people/page.tsx | 159 ++++++++++++--------- client/src/components/UserSearch/index.tsx | 120 ++++++++++++++++ server/src/routes/admin/communityRouter.ts | 35 ++++- 3 files changed, 246 insertions(+), 68 deletions(-) create mode 100644 client/src/components/UserSearch/index.tsx diff --git a/client/src/app/admin/darak/people/page.tsx b/client/src/app/admin/darak/people/page.tsx index cf0af1c..eacd890 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,28 +409,29 @@ 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}명) - - - 다락방에 속하지 않은 사용자들입니다. - - + 커뮤니티 관리 + + 0 ? "warning" : "success"} + /> + + + {/* 미배정 사용자 영역 */} + - {noCommunityUser.map((user) => UserBox({ user }))} - - + + 미배정 사용자 ({noCommunityUser.length}명) + + + 다락방에 속하지 않은 사용자들입니다. + + + {noCommunityUser.map((user) => UserBox({ user }))} + + + {/* 커뮤니티 영역 */} + + + + {/* 네비게이션 */} diff --git a/client/src/components/UserSearch/index.tsx b/client/src/components/UserSearch/index.tsx new file mode 100644 index 0000000..bdb0852 --- /dev/null +++ b/client/src/components/UserSearch/index.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect, useMemo } from "react" +import { + TextField, + Autocomplete, + Box, + Typography, + CircularProgress, +} from "@mui/material" +import { get } from "@/config/api" +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 results = await get( + `/admin/community/search-user?name=${request.input}`, + ) + callback(results) + } 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) => ( + + {loading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + sx={{ width: 300 }} + /> + ) +} 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: { From d9e1431ef9791371516ca997efb742bbcee7b12e Mon Sep 17 00:00:00 2001 From: iubns Date: Sun, 25 Jan 2026 00:25:15 +0900 Subject: [PATCH 04/21] fix: scroll and decleated pros --- client/src/app/admin/darak/people/page.tsx | 44 ++++++++++------------ client/src/components/UserSearch/index.tsx | 31 +++------------ 2 files changed, 25 insertions(+), 50 deletions(-) diff --git a/client/src/app/admin/darak/people/page.tsx b/client/src/app/admin/darak/people/page.tsx index eacd890..b65ec5a 100644 --- a/client/src/app/admin/darak/people/page.tsx +++ b/client/src/app/admin/darak/people/page.tsx @@ -430,8 +430,23 @@ export default function People() { } return ( - + + + + 커뮤니티 관리 + + + + + + + - - - 커뮤니티 관리 - - 0 ? "warning" : "success"} - /> - - {/* 미배정 사용자 영역 */} - - - - {/* 네비게이션 */} - {/* 드래그 중인 사용자 표시 */} {selectedUser.current && selectedUser.current.id && ( void, ) => { try { - const results = await get( + const { data } = await axios.get( `/admin/community/search-user?name=${request.input}`, ) - callback(results) + callback(data) } catch (error) { console.error(error) callback([]) @@ -97,22 +91,7 @@ export default function UserSearch({ onSelectUser }: UserSearchProps) { ) }} renderInput={(params) => ( - - {loading ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> + )} sx={{ width: 300 }} /> From a0640ce7f76608a0c9e0118ac528693ddfbbb572 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 26 Jan 2026 19:10:29 +0900 Subject: [PATCH 05/21] =?UTF-8?q?fix:=20=EC=88=98=EB=A0=A8=ED=9A=8C=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=EC=9E=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../retreat-attendance/RetreatAttendanceCard.tsx | 2 +- server/src/routes/soon/soonRouter.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) 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 ( { isWorker: true, isHalf: true, createAt: true, + isCanceled: true, }, }, relations: { @@ -358,15 +359,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) }) From 023e2f0ffaf1d8c87e4b11231b0dccfa0d8460a5 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 26 Jan 2026 19:31:34 +0900 Subject: [PATCH 06/21] fix: server url --- client/src/app/admin/page.tsx | 258 +++++++++++++++++++++------------- client/src/config/axios.ts | 36 +++-- client/src/hooks/useKakao.ts | 5 +- 3 files changed, 187 insertions(+), 112 deletions(-) diff --git a/client/src/app/admin/page.tsx b/client/src/app/admin/page.tsx index d132d97..2c9768c 100644 --- a/client/src/app/admin/page.tsx +++ b/client/src/app/admin/page.tsx @@ -7,11 +7,9 @@ import { Chip, List, Alert, - Divider, ListItem, Typography, CardContent, - LinearProgress, CircularProgress, } from "@mui/material" import { useSetAtom } from "jotai" @@ -242,106 +240,166 @@ 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} + + + ) + })} + + ) + })()} + + + + + 전체 + { +export const GetUrl = () => { const target = process.env.NEXT_PUBLIC_API_TARGET - let PORT = 8000 + let SERVER_PORT = 8000 + let CLIENT_PORT = 8080 switch (target) { case "prod": - PORT = 8000 - return `${process.env.NEXT_PUBLIC_PROD_SERVER}:${PORT}` + SERVER_PORT = 8000 + CLIENT_PORT = 8080 + 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}:${PORT}` + SERVER_PORT = 8001 + CLIENT_PORT = 80 + return { + host: process.env.NEXT_PUBLIC_DEV_SERVER, + serverPort: SERVER_PORT, + clientPort: CLIENT_PORT, + } case "local": default: - PORT = 8000 - return `${process.env.NEXT_PUBLIC_LOCAL_SERVER}:${PORT}` + SERVER_PORT = 8000 + CLIENT_PORT = 80 + return { + host: process.env.NEXT_PUBLIC_LOCAL_SERVER, + serverPort: SERVER_PORT, + clientPort: CLIENT_PORT, + } } } -const SERVER_URL = GetServerUrl() +const URL = GetUrl() -export const SERVER_FULL_PATH = `${SERVER_URL}` +export const SERVER_FULL_PATH = `${URL.host}:${URL.serverPort}` const isBrowser = typeof window !== "undefined" diff --git a/client/src/hooks/useKakao.ts b/client/src/hooks/useKakao.ts index 5aaa384..8128d89 100644 --- a/client/src/hooks/useKakao.ts +++ b/client/src/hooks/useKakao.ts @@ -1,4 +1,4 @@ -import { GetServerUrl } from "@/config/axios" +import { GetUrl } from "@/config/axios" import { useEffect } from "react" export default function useKakaoHook() { @@ -39,8 +39,9 @@ export default function useKakaoHook() { } async function executeKakaoLogin(redirectUri: string = "") { + const URL = GetUrl() await Kakao.Auth.authorize({ - redirectUri: `http://localhost:8080/common/login`, + redirectUri: `${URL.host}:${URL.clientPort}/common/login`, state: redirectUri, }) return From 5296ac9f3f14e4db5c1416442204e81e99ee4f8a Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 26 Jan 2026 19:32:08 +0900 Subject: [PATCH 07/21] update: admin/graph --- client/src/app/admin/page.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/client/src/app/admin/page.tsx b/client/src/app/admin/page.tsx index 2c9768c..3db4cd4 100644 --- a/client/src/app/admin/page.tsx +++ b/client/src/app/admin/page.tsx @@ -314,7 +314,7 @@ function index() { {/* Points and Labels */} {data.map((d, i) => { const maleHigher = d.male >= d.female - + return ( Date: Mon, 26 Jan 2026 19:42:13 +0900 Subject: [PATCH 08/21] fix: port number --- client/src/config/axios.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/config/axios.ts b/client/src/config/axios.ts index 4e9824f..fc75523 100644 --- a/client/src/config/axios.ts +++ b/client/src/config/axios.ts @@ -3,20 +3,20 @@ import axios from "axios" export const GetUrl = () => { const target = process.env.NEXT_PUBLIC_API_TARGET - let SERVER_PORT = 8000 - let CLIENT_PORT = 8080 + let SERVER_PORT = "8000" + let CLIENT_PORT = "8080" switch (target) { case "prod": - SERVER_PORT = 8000 - CLIENT_PORT = 8080 + SERVER_PORT = "8000" + CLIENT_PORT = "8080" return { host: process.env.NEXT_PUBLIC_PROD_SERVER, serverPort: SERVER_PORT, clientPort: CLIENT_PORT, } case "dev": - SERVER_PORT = 8001 - CLIENT_PORT = 80 + SERVER_PORT = "8001" + CLIENT_PORT = "" return { host: process.env.NEXT_PUBLIC_DEV_SERVER, serverPort: SERVER_PORT, @@ -24,8 +24,8 @@ export const GetUrl = () => { } case "local": default: - SERVER_PORT = 8000 - CLIENT_PORT = 80 + SERVER_PORT = "8000" + CLIENT_PORT = "" return { host: process.env.NEXT_PUBLIC_LOCAL_SERVER, serverPort: SERVER_PORT, From 514d37c7e086896e44ebacfaf92eaefe67e2ece5 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 26 Jan 2026 20:04:38 +0900 Subject: [PATCH 09/21] fix: redirect url --- client/src/config/axios.ts | 14 +++++++------- client/src/hooks/useKakao.ts | 2 +- server/src/util/auth.ts | 4 +--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/client/src/config/axios.ts b/client/src/config/axios.ts index fc75523..5951766 100644 --- a/client/src/config/axios.ts +++ b/client/src/config/axios.ts @@ -3,19 +3,19 @@ import axios from "axios" export const GetUrl = () => { const target = process.env.NEXT_PUBLIC_API_TARGET - let SERVER_PORT = "8000" - let CLIENT_PORT = "8080" + let SERVER_PORT = ":8000" + let CLIENT_PORT = ":8080" switch (target) { case "prod": - SERVER_PORT = "8000" - CLIENT_PORT = "8080" + SERVER_PORT = ":8000" + CLIENT_PORT = ":8080" return { host: process.env.NEXT_PUBLIC_PROD_SERVER, serverPort: SERVER_PORT, clientPort: CLIENT_PORT, } case "dev": - SERVER_PORT = "8001" + SERVER_PORT = ":8001" CLIENT_PORT = "" return { host: process.env.NEXT_PUBLIC_DEV_SERVER, @@ -24,7 +24,7 @@ export const GetUrl = () => { } case "local": default: - SERVER_PORT = "8000" + SERVER_PORT = ":8000" CLIENT_PORT = "" return { host: process.env.NEXT_PUBLIC_LOCAL_SERVER, @@ -36,7 +36,7 @@ export const GetUrl = () => { const URL = GetUrl() -export const SERVER_FULL_PATH = `${URL.host}:${URL.serverPort}` +export const SERVER_FULL_PATH = `${URL.host}${URL.serverPort}` const isBrowser = typeof window !== "undefined" diff --git a/client/src/hooks/useKakao.ts b/client/src/hooks/useKakao.ts index 8128d89..02319c8 100644 --- a/client/src/hooks/useKakao.ts +++ b/client/src/hooks/useKakao.ts @@ -41,7 +41,7 @@ export default function useKakaoHook() { async function executeKakaoLogin(redirectUri: string = "") { const URL = GetUrl() await Kakao.Auth.authorize({ - redirectUri: `${URL.host}:${URL.clientPort}/common/login`, + redirectUri: `${URL.host}${URL.clientPort}/common/login`, state: redirectUri, }) return diff --git a/server/src/util/auth.ts b/server/src/util/auth.ts index fa367f0..d30bf51 100644 --- a/server/src/util/auth.ts +++ b/server/src/util/auth.ts @@ -119,7 +119,6 @@ export async function getKakaoIdFromAccessToken( //Todo: cors에 있는 것도 그렇고, 어떻게 관리 해야 하나? const target = process.env.NEXT_PUBLIC_API_TARGET function getServerUrl() { - let PORT = 8000 switch (target) { case "prod": return `https://nuon.iubns.net` @@ -127,7 +126,6 @@ function getServerUrl() { return `https://nuon-dev.iubns.net` case "local": default: - PORT = 8080 - return `http://localhost:${PORT}` + return `http://localhost:8080` } } From 98e86f0a0368dcaf22909ae20c2f184a1afbb30c Mon Sep 17 00:00:00 2001 From: iubns Date: Tue, 27 Jan 2026 00:19:19 +0900 Subject: [PATCH 10/21] rm: DS_Store --- .DS_Store | Bin 8196 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c4a20ff032ce3613172aa29bb3972d50a32dd34d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM&1(}u6n~S%WNT4!P$+`1U=@UtrdDl>2r*3$A_Zwg!4KMO6OzTvPRK`d2%&rM zNkO;0(@phwVEImY z24DgJEUbj-acq85$WC>r#MHVZqrk8Nbaqcb2CCpfTR*?2 z=AV7jZFOEKI39O!%-VhRa_g-1`C8asKG5E%4ZaOnN5w8p?fHcobimQQ1B}1M)guf& zhUML_!S^3K6vGf}5Xoqk9*=rfC1D*r)S&`Cw6A-7U0p8)OMWsxMZq3Gi0gD)4aI%$ zG(5M}&St-`SUhngnX*!;@zjH2jdzRfQoGa2mzwwJ*DcOHXRlv!Zo8H4YWl Date: Tue, 27 Jan 2026 00:30:27 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20=EC=83=88=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20TypeORM=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/entity/newcomer.ts | 74 ++++++++++++++++++++++++++ server/src/entity/newcomerEducation.ts | 33 ++++++++++++ server/src/entity/types.ts | 16 ++++++ server/src/model/dataSource.ts | 5 ++ 4 files changed, 128 insertions(+) create mode 100644 server/src/entity/newcomer.ts create mode 100644 server/src/entity/newcomerEducation.ts diff --git a/server/src/entity/newcomer.ts b/server/src/entity/newcomer.ts new file mode 100644 index 0000000..13ed068 --- /dev/null +++ b/server/src/entity/newcomer.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm" +import { User } from "./user" +import { NewcomerStatus } from "./types" +import { NewcomerEducation } from "./newcomerEducation" + +@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 // 등반일 + + @Column({ nullable: true }) + assignment: string // 배정 + + @Column({ nullable: true }) + deletionDate: string // 삭제일 + + @Column({ nullable: true }) + pendingDate: string // 보류일 + + @ManyToOne(() => User) + @JoinColumn({ name: "managerId" }) + manager: User + + @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 +} diff --git a/server/src/entity/newcomerEducation.ts b/server/src/entity/newcomerEducation.ts new file mode 100644 index 0000000..be06fd1 --- /dev/null +++ b/server/src/entity/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/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/model/dataSource.ts b/server/src/model/dataSource.ts index 59c67b3..9be195a 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -14,6 +14,8 @@ 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" +import { NewcomerEducation } from "../entity/newcomerEducation" const dataSource = new DataSource(require("../../ormconfig.js")) @@ -34,5 +36,8 @@ 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 default dataSource From bbd3da453fc3e3a2ee9d541847d85425e2190f53 Mon Sep 17 00:00:00 2001 From: nrbns357 Date: Thu, 29 Jan 2026 22:03:02 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20TypeORM=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration/1769693832000-CreateNewcomer.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 server/src/migration/1769693832000-CreateNewcomer.ts diff --git a/server/src/migration/1769693832000-CreateNewcomer.ts b/server/src/migration/1769693832000-CreateNewcomer.ts new file mode 100644 index 0000000..6e2719b --- /dev/null +++ b/server/src/migration/1769693832000-CreateNewcomer.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class CreateNewcomer1769693832000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 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 '등반일', + \`assignment\` varchar(255) NULL COMMENT '배정', + \`deletionDate\` varchar(255) NULL COMMENT '삭제일', + \`pendingDate\` varchar(255) NULL COMMENT '보류일', + \`managerId\` varchar(36) NULL, + \`createdAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + 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 (\`managerId\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + `) + + // NewcomerEducation 테이블 생성 + await queryRunner.query(` + CREATE TABLE \`newcomer_education\` ( + \`id\` varchar(36) NOT NULL, + \`newcomerId\` varchar(36) NULL, + \`worshipScheduleId\` varchar(36) 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\``) + } +} From 964bcf619f28ab76b39acf2ce05aaa35473418c5 Mon Sep 17 00:00:00 2001 From: nrbns357 Date: Fri, 30 Jan 2026 00:17:37 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20TypeORM=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/migration/1769693832000-CreateNewcomer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/migration/1769693832000-CreateNewcomer.ts b/server/src/migration/1769693832000-CreateNewcomer.ts index 6e2719b..dd68c4d 100644 --- a/server/src/migration/1769693832000-CreateNewcomer.ts +++ b/server/src/migration/1769693832000-CreateNewcomer.ts @@ -30,7 +30,7 @@ export class CreateNewcomer1769693832000 implements MigrationInterface { CREATE TABLE \`newcomer_education\` ( \`id\` varchar(36) NOT NULL, \`newcomerId\` varchar(36) NULL, - \`worshipScheduleId\` varchar(36) NULL, + \`worshipScheduleId\` int NULL, \`lectureType\` enum ('OT', 'L1', 'L2', 'L3', 'L4', 'L5') NOT NULL, \`memo\` text NULL, PRIMARY KEY (\`id\`), From fd36aeee6591f183ac01f1839a6d1c8a466cfb58 Mon Sep 17 00:00:00 2001 From: iubns Date: Sat, 31 Jan 2026 17:48:46 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20=EC=83=88=EA=B0=80=EC=A1=B1=20?= =?UTF-8?q?=EB=8B=B4=EB=8B=B9=EC=9E=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/entity/{ => newcomer}/newcomer.ts | 4 ++-- .../{ => newcomer}/newcomerEducation.ts | 4 ++-- server/src/entity/newcomer/newcomerManager.ts | 12 ++++++++++ .../1769849191532-NewcommerManager.ts | 24 +++++++++++++++++++ server/src/model/dataSource.ts | 4 ++-- 5 files changed, 42 insertions(+), 6 deletions(-) rename server/src/entity/{ => newcomer}/newcomer.ts (95%) rename server/src/entity/{ => newcomer}/newcomerEducation.ts (86%) create mode 100644 server/src/entity/newcomer/newcomerManager.ts create mode 100644 server/src/migration/1769849191532-NewcommerManager.ts diff --git a/server/src/entity/newcomer.ts b/server/src/entity/newcomer/newcomer.ts similarity index 95% rename from server/src/entity/newcomer.ts rename to server/src/entity/newcomer/newcomer.ts index 13ed068..298e986 100644 --- a/server/src/entity/newcomer.ts +++ b/server/src/entity/newcomer/newcomer.ts @@ -8,8 +8,8 @@ import { CreateDateColumn, UpdateDateColumn, } from "typeorm" -import { User } from "./user" -import { NewcomerStatus } from "./types" +import { User } from "../user" +import { NewcomerStatus } from "../types" import { NewcomerEducation } from "./newcomerEducation" @Entity() diff --git a/server/src/entity/newcomerEducation.ts b/server/src/entity/newcomer/newcomerEducation.ts similarity index 86% rename from server/src/entity/newcomerEducation.ts rename to server/src/entity/newcomer/newcomerEducation.ts index be06fd1..489bdd2 100644 --- a/server/src/entity/newcomerEducation.ts +++ b/server/src/entity/newcomer/newcomerEducation.ts @@ -6,8 +6,8 @@ import { JoinColumn, } from "typeorm" import { Newcomer } from "./newcomer" -import { WorshipSchedule } from "./worshipSchedule" -import { EducationLecture } from "./types" +import { WorshipSchedule } from "../worshipSchedule" +import { EducationLecture } from "../types" @Entity() export class NewcomerEducation { diff --git a/server/src/entity/newcomer/newcomerManager.ts b/server/src/entity/newcomer/newcomerManager.ts new file mode 100644 index 0000000..b4550e4 --- /dev/null +++ b/server/src/entity/newcomer/newcomerManager.ts @@ -0,0 +1,12 @@ +import { Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from "typeorm" +import { User } from "../user" + +@Entity() +export class NewcomerManager { + @PrimaryGeneratedColumn("uuid") + id: string + + @OneToOne(() => User) + @JoinColumn({ name: "userId" }) + user: User +} diff --git a/server/src/migration/1769849191532-NewcommerManager.ts b/server/src/migration/1769849191532-NewcommerManager.ts new file mode 100644 index 0000000..ece6a82 --- /dev/null +++ b/server/src/migration/1769849191532-NewcommerManager.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class NewcommerManager1769849191532 implements MigrationInterface { + name = "NewcommerManager1769849191532" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`newcomer_manager\` (\`id\` uuid NOT NULL, \`userId\` uuid NULL, UNIQUE INDEX \`REL_b7d878973e0b2a9b64a7214b03\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ) + await queryRunner.query( + `ALTER TABLE \`newcomer_manager\` ADD CONSTRAINT \`FK_b7d878973e0b2a9b64a7214b036\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`newcomer_manager\` DROP FOREIGN KEY \`FK_b7d878973e0b2a9b64a7214b036\``, + ) + await queryRunner.query( + `DROP INDEX \`REL_b7d878973e0b2a9b64a7214b03\` ON \`newcomer_manager\``, + ) + await queryRunner.query(`DROP TABLE \`newcomer_manager\``) + } +} diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index 9be195a..ca1ad67 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -14,8 +14,8 @@ 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" -import { NewcomerEducation } from "../entity/newcomerEducation" +import { Newcomer } from "../entity/newcomer/newcomer" +import { NewcomerEducation } from "../entity/newcomer/newcomerEducation" const dataSource = new DataSource(require("../../ormconfig.js")) From da5e276024a535d0a5680b0cc8013e97796234ac Mon Sep 17 00:00:00 2001 From: nrbns357 Date: Sat, 31 Jan 2026 18:13:12 +0900 Subject: [PATCH 15/21] =?UTF-8?q?fix:=20=EC=83=88=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=82=AD=EC=A0=9C=EB=82=A0?= =?UTF-8?q?=EC=A7=9C,=EC=84=AC=EA=B9=80=EC=9D=B4=20=EC=A1=B0=EC=9D=B8=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/entity/newcomer/newcomer.ts | 13 +- .../migration/1769693832000-CreateNewcomer.ts | 22 +- .../1769849191532-NewcommerManager.ts | 24 -- server/src/routes/index.ts | 2 + server/src/routes/newcomer/newcomerRouter.ts | 247 ++++++++++++++++++ 5 files changed, 275 insertions(+), 33 deletions(-) delete mode 100644 server/src/migration/1769849191532-NewcommerManager.ts create mode 100644 server/src/routes/newcomer/newcomerRouter.ts diff --git a/server/src/entity/newcomer/newcomer.ts b/server/src/entity/newcomer/newcomer.ts index 298e986..0bba350 100644 --- a/server/src/entity/newcomer/newcomer.ts +++ b/server/src/entity/newcomer/newcomer.ts @@ -6,11 +6,13 @@ import { JoinColumn, OneToMany, CreateDateColumn, + DeleteDateColumn, UpdateDateColumn, } from "typeorm" import { User } from "../user" import { NewcomerStatus } from "../types" import { NewcomerEducation } from "./newcomerEducation" +import { NewcomerManager } from "./newcomerManager" @Entity() export class Newcomer { @@ -46,15 +48,18 @@ export class Newcomer { @Column({ nullable: true }) assignment: string // 배정 - @Column({ nullable: true }) - deletionDate: string // 삭제일 + @DeleteDateColumn({ + type: "timestamp", + nullable: true, + }) + deletedAt: Date | null // 삭제일 @Column({ nullable: true }) pendingDate: string // 보류일 - @ManyToOne(() => User) + @ManyToOne(() => NewcomerManager) @JoinColumn({ name: "managerId" }) - manager: User + manager: NewcomerManager // 섬김이(담당자) @OneToMany(() => NewcomerEducation, (education) => education.newcomer) educationRecords: NewcomerEducation[] diff --git a/server/src/migration/1769693832000-CreateNewcomer.ts b/server/src/migration/1769693832000-CreateNewcomer.ts index dd68c4d..b3b19da 100644 --- a/server/src/migration/1769693832000-CreateNewcomer.ts +++ b/server/src/migration/1769693832000-CreateNewcomer.ts @@ -2,7 +2,18 @@ import { MigrationInterface, QueryRunner } from "typeorm" export class CreateNewcomer1769693832000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - // Newcomer 테이블 생성 + // 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, @@ -14,18 +25,18 @@ export class CreateNewcomer1769693832000 implements MigrationInterface { \`status\` enum ('NORMAL', 'PROMOTED', 'DELETED', 'PENDING') NOT NULL DEFAULT 'NORMAL', \`promotionDate\` varchar(255) NULL COMMENT '등반일', \`assignment\` varchar(255) NULL COMMENT '배정', - \`deletionDate\` varchar(255) NULL COMMENT '삭제일', + \`deletedAt\` timestamp(6) NULL COMMENT '삭제일 (soft delete)', \`pendingDate\` varchar(255) NULL COMMENT '보류일', - \`managerId\` varchar(36) NULL, + \`managerId\` 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), 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 (\`managerId\`) REFERENCES \`user\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION + CONSTRAINT \`FK_newcomer_manager\` FOREIGN KEY (\`managerId\`) REFERENCES \`newcomer_manager\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci `) - // NewcomerEducation 테이블 생성 + // 3. NewcomerEducation 테이블 생성 await queryRunner.query(` CREATE TABLE \`newcomer_education\` ( \`id\` varchar(36) NOT NULL, @@ -43,5 +54,6 @@ export class CreateNewcomer1769693832000 implements MigrationInterface { 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/migration/1769849191532-NewcommerManager.ts b/server/src/migration/1769849191532-NewcommerManager.ts deleted file mode 100644 index ece6a82..0000000 --- a/server/src/migration/1769849191532-NewcommerManager.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm" - -export class NewcommerManager1769849191532 implements MigrationInterface { - name = "NewcommerManager1769849191532" - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TABLE \`newcomer_manager\` (\`id\` uuid NOT NULL, \`userId\` uuid NULL, UNIQUE INDEX \`REL_b7d878973e0b2a9b64a7214b03\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, - ) - await queryRunner.query( - `ALTER TABLE \`newcomer_manager\` ADD CONSTRAINT \`FK_b7d878973e0b2a9b64a7214b036\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, - ) - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE \`newcomer_manager\` DROP FOREIGN KEY \`FK_b7d878973e0b2a9b64a7214b036\``, - ) - await queryRunner.query( - `DROP INDEX \`REL_b7d878973e0b2a9b64a7214b03\` ON \`newcomer_manager\``, - ) - await queryRunner.query(`DROP TABLE \`newcomer_manager\``) - } -} 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..3d2eccc --- /dev/null +++ b/server/src/routes/newcomer/newcomerRouter.ts @@ -0,0 +1,247 @@ +import express from "express" + +import { + newcomerDatabase, + newcomerEducationDatabase, + userDatabase, + worshipScheduleDatabase, +} from "../../model/dataSource" +import { checkJwt } from "../../util/util" +import { EducationLecture, NewcomerStatus } from "../../entity/types" + +const router = express.Router() + +// 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, assignment } = req.body + + if (!name) { + res.status(400).send({ error: "이름은 필수입니다." }) + return + } + + try { + // 인도자 확인 + let guider = null + if (guiderId) { + guider = await userDatabase.findOne({ where: { id: guiderId } }) + } + + const newcomer = newcomerDatabase.create({ + name, + yearOfBirth: yearOfBirth ? parseInt(yearOfBirth, 10) : null, + gender: gender || null, + phone: phone?.replace(/[^\d]/g, "") || null, + guider, + assignment: assignment || null, + manager: user, + 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: "새신자 등록에 실패했습니다." }) + } +}) + +// 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, + manager: 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, + manager: newcomer.manager + ? { id: newcomer.manager.id, name: newcomer.manager.name } + : null, + })) + + res.status(200).send(sanitizedNewcomers) + } catch (error) { + console.error("Error fetching newcomers:", error) + res.status(500).send({ error: "새신자 목록 조회에 실패했습니다." }) + } +}) + +// 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, + manager: true, + educationRecords: { + worshipSchedule: true, + }, + }, + order: { + createdAt: "DESC", + }, + }) + + // 테이블 형식으로 변환: 각 새신자별 교육 수강 현황 + const educationTable = newcomers.map((newcomer) => { + // 교육 기록을 lectureType 기준으로 맵핑 + const educationMap: Record = {} + for (const lecture of Object.values(EducationLecture)) { + const record = newcomer.educationRecords?.find( + (r) => r.lectureType === lecture, + ) + educationMap[lecture] = record + ? { + id: record.id, + completed: true, + worshipSchedule: record.worshipSchedule, + memo: record.memo, + } + : { completed: false } + } + + 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, + manager: newcomer.manager + ? { id: newcomer.manager.id, name: newcomer.manager.name } + : null, + assignment: newcomer.assignment, + createdAt: newcomer.createdAt, + education: educationMap, + } + }) + + res.status(200).send(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 + + if (!lectureType) { + res.status(400).send({ error: "강의 타입은 필수입니다." }) + return + } + + // lectureType 유효성 검사 + if (!Object.values(EducationLecture).includes(lectureType)) { + res.status(400).send({ error: "유효하지 않은 강의 타입입니다." }) + return + } + + try { + const newcomer = await newcomerDatabase.findOne({ + where: { id: newcomerId }, + }) + + if (!newcomer) { + res.status(404).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: "교육 정보 업데이트에 실패했습니다." }) + } +}) + +export default router From 91974f3dc75e0f6c65c5e1c6163b03ce7b043990 Mon Sep 17 00:00:00 2001 From: nrbns357 Date: Sun, 1 Feb 2026 02:44:51 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20=EC=83=88=EA=B0=80=EC=A1=B1?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=EC=A1=B0=ED=9A=8C,=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/leader/components/Header/index.tsx | 6 + .../app/leader/newcomer/education/page.tsx | 414 ++++++++++++++++++ client/src/app/leader/newcomer/layout.tsx | 11 + .../newcomer/management/NewcomerFilter.tsx | 122 ++++++ .../newcomer/management/NewcomerForm.tsx | 153 +++++++ .../newcomer/management/NewcomerTable.tsx | 117 +++++ .../app/leader/newcomer/management/page.tsx | 220 ++++++++++ client/src/app/leader/newcomer/page.tsx | 26 ++ server/src/entity/newcomer/newcomer.ts | 22 +- .../migration/1769693832000-CreateNewcomer.ts | 9 +- server/src/model/dataSource.ts | 2 + server/src/routes/newcomer/newcomerRouter.ts | 150 +++++-- 12 files changed, 1206 insertions(+), 46 deletions(-) create mode 100644 client/src/app/leader/newcomer/education/page.tsx create mode 100644 client/src/app/leader/newcomer/layout.tsx create mode 100644 client/src/app/leader/newcomer/management/NewcomerFilter.tsx create mode 100644 client/src/app/leader/newcomer/management/NewcomerForm.tsx create mode 100644 client/src/app/leader/newcomer/management/NewcomerTable.tsx create mode 100644 client/src/app/leader/newcomer/management/page.tsx create mode 100644 client/src/app/leader/newcomer/page.tsx diff --git a/client/src/app/leader/components/Header/index.tsx b/client/src/app/leader/components/Header/index.tsx index d67a0b4..ae421f2 100644 --- a/client/src/app/leader/components/Header/index.tsx +++ b/client/src/app/leader/components/Header/index.tsx @@ -36,6 +36,12 @@ export default function Header() { path: "/leader/attendance", type: "menu", }, + { + title: "새신자 관리", + icon: , + path: "/leader/newcomer", + type: "menu", + }, { title: "순원 수련회 접수 조회", icon: , diff --git a/client/src/app/leader/newcomer/education/page.tsx b/client/src/app/leader/newcomer/education/page.tsx new file mode 100644 index 0000000..5de969d --- /dev/null +++ b/client/src/app/leader/newcomer/education/page.tsx @@ -0,0 +1,414 @@ +"use client" + +import axios from "@/config/axios" +import { + Stack, + Box, + Card, + CardContent, + Typography, + Paper, + Chip, + MenuItem, + Select, +} from "@mui/material" +import { useEffect, useState } from "react" +import useAuth from "@/hooks/useAuth" +import { useSetAtom } from "jotai" +import { NotificationMessage } from "@/state/notification" + +interface WorshipSchedule { + id: number + date: string +} + +interface EducationRecord { + id: string + lectureType: string + worshipScheduleId: number + memo: string | null +} + +interface NewcomerEducation { + id: string + name: string + yearOfBirth: number | null + gender: string | null + education: Record +} + +interface EducationResponse { + worshipSchedules: WorshipSchedule[] + newcomers: NewcomerEducation[] +} + +// 강의 타입별 색상 +const lectureColors: Record = { + "": "transparent", + OT: "#b8f85d", + "1강": "#fdf171", + "2강": "#fdf171", + "3강": "#fdf171", + "4강": "#fdf171", + "5강": "#fdf171", +} + +const lectureOptions = ["", "OT", "1강", "2강", "3강", "4강", "5강"] + +// 테이블 셀 너비 상수 +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") + setEducationData(data) + } catch (error) { + console.error("Error fetching education data:", error) + } finally { + setLoading(false) + } + } + + 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 || "" + } + + if (loading) { + return ( + + 로딩 중... + + ) + } + + if (!educationData) { + return ( + + 데이터를 불러올 수 없습니다. + + ) + } + + return ( + + + {/* 상단 헤더 */} + + + + 새신자 교육 현황 + + + + + {/* 범례 */} + + + + + 강의: + + {lectureOptions + .filter((l) => l) + .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 + + 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..a9533bd --- /dev/null +++ b/client/src/app/leader/newcomer/layout.tsx @@ -0,0 +1,11 @@ +"use client" + +import { Stack } from "@mui/material" + +export default function NewcomerLayout({ + children, +}: { + children: React.ReactNode +}) { + return {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..21956ec --- /dev/null +++ b/client/src/app/leader/newcomer/management/NewcomerForm.tsx @@ -0,0 +1,153 @@ +import { Box, Button, MenuItem, Stack, TextField } from "@mui/material" + +interface Newcomer { + id: string + name: string + yearOfBirth: number | null + phone: string | null + gender: "man" | "woman" | "" | null +} + +interface NewcomerFormProps { + selectedNewcomer: Newcomer + onDataChange: (key: string, value: any) => void + onSave: () => void + onDelete: () => void + onClear: () => void +} + +export default function NewcomerForm({ + selectedNewcomer, + onDataChange, + onSave, + onDelete, + onClear, +}: 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 }} + > + + + + + + ) +} 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..30e126d --- /dev/null +++ b/client/src/app/leader/newcomer/management/page.tsx @@ -0,0 +1,220 @@ +"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 +} + +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("") + + useEffect(() => { + isLeaderIfNotExit("/leader/newcomer/management") + fetchData() + }, []) + + async function fetchData() { + try { + const { data } = await axios.get("/newcomer") + setNewcomerList(data) + } catch (error) { + console.error("Error fetching newcomers:", error) + setNotificationMessage("새신자 목록 조회에 실패했습니다.") + } + } + + function clearSelectedNewcomer() { + setSelectedNewcomer(emptyNewcomer) + } + + function onChangeData(key: string, value: any) { + setSelectedNewcomer({ ...selectedNewcomer, [key]: value }) + } + + async function saveData() { + try { + if (selectedNewcomer.id) { + // TODO: 수정 API 구현 필요 + // await axios.put(`/newcomer/${selectedNewcomer.id}`, selectedNewcomer) + setNotificationMessage("새신자 정보가 수정되었습니다.") + } else { + await axios.post("/newcomer", { + name: selectedNewcomer.name, + yearOfBirth: selectedNewcomer.yearOfBirth, + gender: selectedNewcomer.gender, + phone: selectedNewcomer.phone, + }) + setNotificationMessage("새신자가 추가되었습니다.") + } + await fetchData() + clearSelectedNewcomer() + } catch (error) { + setNotificationMessage("저장 중 오류가 발생했습니다.") + } + } + + async function deleteNewcomer() { + if (selectedNewcomer.id && confirm("정말로 삭제하시겠습니까?")) { + try { + // TODO: 삭제 API 구현 필요 + // 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/page.tsx b/client/src/app/leader/newcomer/page.tsx new file mode 100644 index 0000000..86cb6b0 --- /dev/null +++ b/client/src/app/leader/newcomer/page.tsx @@ -0,0 +1,26 @@ +"use client" + +import useAuth from "@/hooks/useAuth" +import { Stack, Typography } from "@mui/material" +import { useEffect } from "react" +import Link from "next/link" + +export default function NewcomerPage() { + const { isLeaderIfNotExit } = useAuth() + + useEffect(() => { + isLeaderIfNotExit("/leader/newcomer") + }, []) + + return ( + + + 새신자 관리 + + + 새신자 등록/조회 + 교육 현황 + + + ) +} diff --git a/server/src/entity/newcomer/newcomer.ts b/server/src/entity/newcomer/newcomer.ts index 0bba350..6427294 100644 --- a/server/src/entity/newcomer/newcomer.ts +++ b/server/src/entity/newcomer/newcomer.ts @@ -10,6 +10,7 @@ import { UpdateDateColumn, } from "typeorm" import { User } from "../user" +import { Community } from "../community" import { NewcomerStatus } from "../types" import { NewcomerEducation } from "./newcomerEducation" import { NewcomerManager } from "./newcomerManager" @@ -45,21 +46,16 @@ export class Newcomer { @Column({ nullable: true }) promotionDate: string // 등반일 - @Column({ nullable: true }) - assignment: string // 배정 - - @DeleteDateColumn({ - type: "timestamp", - nullable: true, - }) - deletedAt: Date | null // 삭제일 + @ManyToOne(() => Community, { nullable: true }) + @JoinColumn({ name: "assignmentId" }) + assignment: Community // 배정 (등반 후 배정받는 순) @Column({ nullable: true }) pendingDate: string // 보류일 @ManyToOne(() => NewcomerManager) - @JoinColumn({ name: "managerId" }) - manager: NewcomerManager // 섬김이(담당자) + @JoinColumn({ name: "newcomerManagerId" }) + newcomerManager: NewcomerManager // 섬김이(담당자) @OneToMany(() => NewcomerEducation, (education) => education.newcomer) educationRecords: NewcomerEducation[] @@ -76,4 +72,10 @@ export class Newcomer { onUpdate: "CURRENT_TIMESTAMP(6)", }) updatedAt: Date + + @DeleteDateColumn({ + type: "timestamp", + nullable: true, + }) + deletedAt: Date | null // 삭제일 } diff --git a/server/src/migration/1769693832000-CreateNewcomer.ts b/server/src/migration/1769693832000-CreateNewcomer.ts index b3b19da..c63b902 100644 --- a/server/src/migration/1769693832000-CreateNewcomer.ts +++ b/server/src/migration/1769693832000-CreateNewcomer.ts @@ -24,15 +24,16 @@ export class CreateNewcomer1769693832000 implements MigrationInterface { \`guiderId\` varchar(36) NULL COMMENT '인도자', \`status\` enum ('NORMAL', 'PROMOTED', 'DELETED', 'PENDING') NOT NULL DEFAULT 'NORMAL', \`promotionDate\` varchar(255) NULL COMMENT '등반일', - \`assignment\` varchar(255) NULL COMMENT '배정', - \`deletedAt\` timestamp(6) NULL COMMENT '삭제일 (soft delete)', + \`assignmentId\` int NULL COMMENT '배정 (등반 후 배정받는 순)', \`pendingDate\` varchar(255) NULL COMMENT '보류일', - \`managerId\` varchar(36) 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 (\`managerId\`) REFERENCES \`newcomer_manager\`(\`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 `) diff --git a/server/src/model/dataSource.ts b/server/src/model/dataSource.ts index ca1ad67..b85a517 100644 --- a/server/src/model/dataSource.ts +++ b/server/src/model/dataSource.ts @@ -16,6 +16,7 @@ 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")) @@ -39,5 +40,6 @@ 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/newcomer/newcomerRouter.ts b/server/src/routes/newcomer/newcomerRouter.ts index 3d2eccc..633675c 100644 --- a/server/src/routes/newcomer/newcomerRouter.ts +++ b/server/src/routes/newcomer/newcomerRouter.ts @@ -1,8 +1,10 @@ import express from "express" import { + communityDatabase, newcomerDatabase, newcomerEducationDatabase, + newcomerManagerDatabase, userDatabase, worshipScheduleDatabase, } from "../../model/dataSource" @@ -11,6 +13,20 @@ 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) @@ -19,7 +35,7 @@ router.post("/", async (req, res) => { return } - const { name, yearOfBirth, gender, phone, guiderId, assignment } = req.body + const { name, yearOfBirth, gender, phone, guiderId, assignmentId } = req.body if (!name) { res.status(400).send({ error: "이름은 필수입니다." }) @@ -33,14 +49,25 @@ router.post("/", async (req, res) => { guider = await userDatabase.findOne({ where: { id: guiderId } }) } + // NewcomerManager 찾거나 생성 + const 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: assignment || null, - manager: user, + assignment, + newcomerManager, status: NewcomerStatus.NORMAL, }) @@ -72,7 +99,10 @@ router.get("/", async (req, res) => { where: whereCondition, relations: { guider: true, - manager: true, + assignment: true, + newcomerManager: { + user: true, + }, educationRecords: { worshipSchedule: true, }, @@ -88,19 +118,31 @@ router.get("/", async (req, res) => { guider: newcomer.guider ? { id: newcomer.guider.id, name: newcomer.guider.name } : null, - manager: newcomer.manager - ? { id: newcomer.manager.id, name: newcomer.manager.name } + 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) { - console.error("Error fetching newcomers:", error) - res.status(500).send({ error: "새신자 목록 조회에 실패했습니다." }) + } catch (error: any) { + console.error("Error fetching newcomers:", error.message, error.stack) + res.status(500).send({ + error: "새신자 목록 조회에 실패했습니다.", + detail: error.message, + }) } }) -// 3. 새신자 교육 출석 조회 (모든 새신자 + 교육 현황 테이블) +// 3. 새신자 교육 출석 조회 (날짜별 테이블 형식 - 출석 테이블과 동일한 구조) router.get("/education", async (req, res) => { const user = await checkJwt(req) if (!user) { @@ -116,11 +158,15 @@ router.get("/education", async (req, res) => { whereCondition.status = status } + // 새신자 조회 (교육 기록 포함) const newcomers = await newcomerDatabase.find({ where: whereCondition, relations: { guider: true, - manager: true, + assignment: true, + newcomerManager: { + user: true, + }, educationRecords: { worshipSchedule: true, }, @@ -130,23 +176,51 @@ router.get("/education", async (req, res) => { }, }) - // 테이블 형식으로 변환: 각 새신자별 교육 수강 현황 - const educationTable = newcomers.map((newcomer) => { - // 교육 기록을 lectureType 기준으로 맵핑 - const educationMap: Record = {} - for (const lecture of Object.values(EducationLecture)) { - const record = newcomer.educationRecords?.find( - (r) => r.lectureType === lecture, - ) - educationMap[lecture] = record - ? { - id: record.id, - completed: true, - worshipSchedule: record.worshipSchedule, - memo: record.memo, - } - : { completed: false } + // 최근 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() + + console.log("eightWeeksAgoStr:", eightWeeksAgoStr) + console.log("recentSchedules count:", recentSchedules.length) + + // 날짜별 스케줄 맵 생성 + 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) } + }) + + console.log("sortedDates:", sortedDates) + + // 테이블 형식으로 변환: 각 새신자별로 날짜 → 강의 타입 매핑 + 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, @@ -158,16 +232,28 @@ router.get("/education", async (req, res) => { guider: newcomer.guider ? { id: newcomer.guider.id, name: newcomer.guider.name } : null, - manager: newcomer.manager - ? { id: newcomer.manager.id, name: newcomer.manager.name } + 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, - assignment: newcomer.assignment, createdAt: newcomer.createdAt, - education: educationMap, + education: educationByDate, // { "2026-01-11": { lectureType: "OT" }, ... } } }) - res.status(200).send(educationTable) + 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: "교육 출석 조회에 실패했습니다." }) From 0ceca60f41710aa968e0da092f77848be9f61745 Mon Sep 17 00:00:00 2001 From: nrbns357 Date: Sun, 1 Feb 2026 04:27:42 +0900 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20=EB=8B=B4=EB=8B=B9=EC=9E=90=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=8F=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/leader/components/Header/index.tsx | 6 + .../app/leader/newcomer/education/page.tsx | 123 ++++++- client/src/app/leader/newcomer/layout.tsx | 48 ++- .../newcomer/management/NewcomerForm.tsx | 40 ++ .../app/leader/newcomer/management/page.tsx | 20 +- .../src/app/leader/newcomer/managers/page.tsx | 344 ++++++++++++++++++ client/src/app/leader/newcomer/page.tsx | 1 + server/src/entity/newcomer/newcomerManager.ts | 12 +- server/src/routes/newcomer/newcomerRouter.ts | 202 +++++++++- 9 files changed, 760 insertions(+), 36 deletions(-) create mode 100644 client/src/app/leader/newcomer/managers/page.tsx diff --git a/client/src/app/leader/components/Header/index.tsx b/client/src/app/leader/components/Header/index.tsx index ae421f2..ccb1390 100644 --- a/client/src/app/leader/components/Header/index.tsx +++ b/client/src/app/leader/components/Header/index.tsx @@ -42,6 +42,12 @@ export default function Header() { path: "/leader/newcomer", type: "menu", }, + { + title: "담당자 관리", + icon: , + path: "/leader/newcomer/managers", + type: "menu", + }, { title: "순원 수련회 접수 조회", icon: , diff --git a/client/src/app/leader/newcomer/education/page.tsx b/client/src/app/leader/newcomer/education/page.tsx index 5de969d..3b16193 100644 --- a/client/src/app/leader/newcomer/education/page.tsx +++ b/client/src/app/leader/newcomer/education/page.tsx @@ -42,18 +42,32 @@ interface EducationResponse { newcomers: NewcomerEducation[] } -// 강의 타입별 색상 +// 강의 타입별 색상 (value 기준) const lectureColors: Record = { "": "transparent", OT: "#b8f85d", - "1강": "#fdf171", - "2강": "#fdf171", - "3강": "#fdf171", - "4강": "#fdf171", - "5강": "#fdf171", + L1: "#fdf171", + L2: "#fdf171", + L3: "#fdf171", + L4: "#fdf171", + L5: "#fdf171", } -const lectureOptions = ["", "OT", "1강", "2강", "3강", "4강", "5강"] +// 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 @@ -77,7 +91,16 @@ export default function NewcomerEducationPage() { try { setLoading(true) const { data } = await axios.get("/newcomer/education") - setEducationData(data) + + // 출석률 기준 정렬 + const sortedNewcomers = sortByAttendanceRate( + data.newcomers, + data.worshipSchedules, + ) + setEducationData({ + ...data, + newcomers: sortedNewcomers, + }) } catch (error) { console.error("Error fetching education data:", error) } finally { @@ -85,6 +108,46 @@ export default function NewcomerEducationPage() { } } + // 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, @@ -154,6 +217,26 @@ export default function NewcomerEducationPage() { 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 ( @@ -195,14 +278,14 @@ export default function NewcomerEducationPage() { 강의: {lectureOptions - .filter((l) => l) + .filter((l) => l.value !== "") .map((lecture) => ( @@ -338,6 +421,10 @@ export default function NewcomerEducationPage() { ) const cellKey = `${newcomer.id}-${schedule.id}` const isSaving = savingCell === cellKey + const usedLectures = getUsedLectureTypes( + newcomer, + schedule.id, + ) return ( - {lectureOptions - .filter((l) => l) + .filter((l) => l.value !== "") .map((lecture) => ( - - {lecture} + + {lecture.label} ))} diff --git a/client/src/app/leader/newcomer/layout.tsx b/client/src/app/leader/newcomer/layout.tsx index a9533bd..9cbc784 100644 --- a/client/src/app/leader/newcomer/layout.tsx +++ b/client/src/app/leader/newcomer/layout.tsx @@ -1,11 +1,55 @@ "use client" -import { Stack } from "@mui/material" +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 }) { - return {children} + 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/NewcomerForm.tsx b/client/src/app/leader/newcomer/management/NewcomerForm.tsx index 21956ec..547fabf 100644 --- a/client/src/app/leader/newcomer/management/NewcomerForm.tsx +++ b/client/src/app/leader/newcomer/management/NewcomerForm.tsx @@ -1,11 +1,20 @@ 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 { @@ -14,6 +23,7 @@ interface NewcomerFormProps { onSave: () => void onDelete: () => void onClear: () => void + managerList: Manager[] } export default function NewcomerForm({ @@ -22,6 +32,7 @@ export default function NewcomerForm({ onSave, onDelete, onClear, + managerList, }: NewcomerFormProps) { return (
    + + + + 담당자 : + + { + 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/page.tsx b/client/src/app/leader/newcomer/management/page.tsx index 30e126d..fac64d4 100644 --- a/client/src/app/leader/newcomer/management/page.tsx +++ b/client/src/app/leader/newcomer/management/page.tsx @@ -26,6 +26,11 @@ interface Newcomer { createdAt: string } +interface Manager { + id: string + user: { id: string; name: string } +} + const emptyNewcomer: Newcomer = { id: "", name: "", @@ -53,6 +58,7 @@ export default function NewcomerManagement() { const [filterGender, setFilterGender] = useState<"" | "man" | "woman">("") const [filterMinYear, setFilterMinYear] = useState("") const [filterMaxYear, setFilterMaxYear] = useState("") + const [managerList, setManagerList] = useState([]) useEffect(() => { isLeaderIfNotExit("/leader/newcomer/management") @@ -61,11 +67,15 @@ export default function NewcomerManagement() { async function fetchData() { try { - const { data } = await axios.get("/newcomer") - setNewcomerList(data) + 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 newcomers:", error) - setNotificationMessage("새신자 목록 조회에 실패했습니다.") + console.error("Error fetching data:", error) + setNotificationMessage("데이터 조회에 실패했습니다.") } } @@ -89,6 +99,7 @@ export default function NewcomerManagement() { yearOfBirth: selectedNewcomer.yearOfBirth, gender: selectedNewcomer.gender, phone: selectedNewcomer.phone, + newcomerManagerId: selectedNewcomer.newcomerManager?.id || null, }) setNotificationMessage("새신자가 추가되었습니다.") } @@ -212,6 +223,7 @@ export default function NewcomerManagement() { onSave={saveData} onDelete={deleteNewcomer} onClear={clearSelectedNewcomer} + managerList={managerList} /> 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..ff8687e --- /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/newcomer/page.tsx b/client/src/app/leader/newcomer/page.tsx index 86cb6b0..9a56222 100644 --- a/client/src/app/leader/newcomer/page.tsx +++ b/client/src/app/leader/newcomer/page.tsx @@ -20,6 +20,7 @@ export default function NewcomerPage() { 새신자 등록/조회 교육 현황 + 담당자 관리 ) diff --git a/server/src/entity/newcomer/newcomerManager.ts b/server/src/entity/newcomer/newcomerManager.ts index b4550e4..8df3c2d 100644 --- a/server/src/entity/newcomer/newcomerManager.ts +++ b/server/src/entity/newcomer/newcomerManager.ts @@ -1,5 +1,12 @@ -import { Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from "typeorm" +import { + Entity, + JoinColumn, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, +} from "typeorm" import { User } from "../user" +import type { Newcomer } from "./newcomer" @Entity() export class NewcomerManager { @@ -9,4 +16,7 @@ export class NewcomerManager { @OneToOne(() => User) @JoinColumn({ name: "userId" }) user: User + + @OneToMany("Newcomer", "newcomerManager") + newcomers: Newcomer[] } diff --git a/server/src/routes/newcomer/newcomerRouter.ts b/server/src/routes/newcomer/newcomerRouter.ts index 633675c..01d9a05 100644 --- a/server/src/routes/newcomer/newcomerRouter.ts +++ b/server/src/routes/newcomer/newcomerRouter.ts @@ -35,7 +35,15 @@ router.post("/", async (req, res) => { return } - const { name, yearOfBirth, gender, phone, guiderId, assignmentId } = req.body + const { + name, + yearOfBirth, + gender, + phone, + guiderId, + assignmentId, + newcomerManagerId, + } = req.body if (!name) { res.status(400).send({ error: "이름은 필수입니다." }) @@ -49,8 +57,17 @@ router.post("/", async (req, res) => { guider = await userDatabase.findOne({ where: { id: guiderId } }) } - // NewcomerManager 찾거나 생성 - const newcomerManager = await getOrCreateNewcomerManager(user) + // NewcomerManager 확인 또는 현재 유저로 생성 + let newcomerManager = null + if (newcomerManagerId) { + newcomerManager = await newcomerManagerDatabase.findOne({ + where: { id: newcomerManagerId }, + }) + } + if (!newcomerManager) { + // 지정된 담당자가 없으면 현재 유저로 자동 생성 + newcomerManager = await getOrCreateNewcomerManager(user) + } // 배정(Community) 확인 let assignment = null @@ -271,17 +288,6 @@ router.put("/:id/education", async (req, res) => { const newcomerId = req.params.id const { lectureType, worshipScheduleId, memo } = req.body - if (!lectureType) { - res.status(400).send({ error: "강의 타입은 필수입니다." }) - return - } - - // lectureType 유효성 검사 - if (!Object.values(EducationLecture).includes(lectureType)) { - res.status(400).send({ error: "유효하지 않은 강의 타입입니다." }) - return - } - try { const newcomer = await newcomerDatabase.findOne({ where: { id: newcomerId }, @@ -292,6 +298,24 @@ router.put("/:id/education", async (req, res) => { 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: { @@ -330,4 +354,154 @@ router.put("/:id/education", async (req, res) => { } }) +// 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 From 2cf69d1e88cb3077e33b5b4c5dbc76d95d87da67 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 9 Feb 2026 19:37:21 +0900 Subject: [PATCH 18/21] =?UTF-8?q?fix:=20=ED=8F=AC=ED=8A=B8=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/leader/components/Header/index.tsx | 10 ++----- client/src/app/leader/newcomer/layout.tsx | 2 +- .../src/app/leader/newcomer/managers/page.tsx | 2 +- client/src/app/leader/newcomer/page.tsx | 27 ------------------- client/src/config/axios.ts | 4 +-- 5 files changed, 6 insertions(+), 39 deletions(-) delete mode 100644 client/src/app/leader/newcomer/page.tsx diff --git a/client/src/app/leader/components/Header/index.tsx b/client/src/app/leader/components/Header/index.tsx index ccb1390..41707b3 100644 --- a/client/src/app/leader/components/Header/index.tsx +++ b/client/src/app/leader/components/Header/index.tsx @@ -37,15 +37,9 @@ export default function Header() { type: "menu", }, { - title: "새신자 관리", + title: "새가족 관리", icon: , - path: "/leader/newcomer", - type: "menu", - }, - { - title: "담당자 관리", - icon: , - path: "/leader/newcomer/managers", + path: "/leader/newcomer/management", type: "menu", }, { diff --git a/client/src/app/leader/newcomer/layout.tsx b/client/src/app/leader/newcomer/layout.tsx index 9cbc784..e679b1d 100644 --- a/client/src/app/leader/newcomer/layout.tsx +++ b/client/src/app/leader/newcomer/layout.tsx @@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation" const menuItems = [ { label: "새신자 등록/조회", path: "/leader/newcomer/management" }, { label: "교육 현황", path: "/leader/newcomer/education" }, - { label: "담당자 관리", path: "/leader/newcomer/managers" }, + { label: "섬김이 관리", path: "/leader/newcomer/managers" }, ] export default function NewcomerLayout({ diff --git a/client/src/app/leader/newcomer/managers/page.tsx b/client/src/app/leader/newcomer/managers/page.tsx index ff8687e..90129a3 100644 --- a/client/src/app/leader/newcomer/managers/page.tsx +++ b/client/src/app/leader/newcomer/managers/page.tsx @@ -114,7 +114,7 @@ export default function ManagerPage() { {/* 헤더 */} - 담당자 관리 + 섬김이 관리 diff --git a/client/src/app/leader/newcomer/page.tsx b/client/src/app/leader/newcomer/page.tsx deleted file mode 100644 index 9a56222..0000000 --- a/client/src/app/leader/newcomer/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client" - -import useAuth from "@/hooks/useAuth" -import { Stack, Typography } from "@mui/material" -import { useEffect } from "react" -import Link from "next/link" - -export default function NewcomerPage() { - const { isLeaderIfNotExit } = useAuth() - - useEffect(() => { - isLeaderIfNotExit("/leader/newcomer") - }, []) - - return ( - - - 새신자 관리 - - - 새신자 등록/조회 - 교육 현황 - 담당자 관리 - - - ) -} diff --git a/client/src/config/axios.ts b/client/src/config/axios.ts index 5951766..3a1f406 100644 --- a/client/src/config/axios.ts +++ b/client/src/config/axios.ts @@ -8,7 +8,7 @@ export const GetUrl = () => { switch (target) { case "prod": SERVER_PORT = ":8000" - CLIENT_PORT = ":8080" + CLIENT_PORT = "" return { host: process.env.NEXT_PUBLIC_PROD_SERVER, serverPort: SERVER_PORT, @@ -25,7 +25,7 @@ export const GetUrl = () => { case "local": default: SERVER_PORT = ":8000" - CLIENT_PORT = "" + CLIENT_PORT = ":8080" return { host: process.env.NEXT_PUBLIC_LOCAL_SERVER, serverPort: SERVER_PORT, From 18a5da632128d636e338845e65c989d64c9f24a9 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 9 Feb 2026 19:50:09 +0900 Subject: [PATCH 19/21] =?UTF-8?q?update:=20=EC=88=98=EB=A0=A8=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=A7=81=ED=81=AC=20=EC=88=A8=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/leader/components/Header/index.tsx | 20 +++++++++------ client/src/components/Header/index.tsx | 2 ++ client/src/hooks/useAuth.ts | 8 +----- server/src/util/auth.ts | 25 ++++++------------- server/src/util/type.ts | 18 +++++++++++++ server/src/util/util.ts | 8 +++--- 6 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 server/src/util/type.ts diff --git a/client/src/app/leader/components/Header/index.tsx b/client/src/app/leader/components/Header/index.tsx index 41707b3..9e6817a 100644 --- a/client/src/app/leader/components/Header/index.tsx +++ b/client/src/app/leader/components/Header/index.tsx @@ -36,21 +36,16 @@ export default function Header() { path: "/leader/attendance", type: "menu", }, - { - title: "새가족 관리", - icon: , - path: "/leader/newcomer/management", - 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: , @@ -59,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 ( (null) -export interface Role { - Admin: boolean - Leader: boolean - VillageLeader: boolean -} - //Todo: 서버와 통합할 수 있는 방법 찾아보기, 지금은 jwt type error로 인해 분리 export interface jwtPayload { id: string diff --git a/server/src/util/auth.ts b/server/src/util/auth.ts index d30bf51..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,10 +56,15 @@ 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, } } 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) From a1969b156399c29aecabb225190d1b2aef6551e0 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 9 Feb 2026 19:51:49 +0900 Subject: [PATCH 20/21] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20console.log=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/retreat/admin/carpooling/page.tsx | 17 ++++++++--------- .../src/app/retreat/admin/check-status/page.tsx | 8 ++++---- client/src/components/receipt/index.tsx | 7 +------ server/src/routes/admin/ai.ts | 1 - server/src/routes/newcomer/newcomerRouter.ts | 5 ----- server/src/routes/soon/soonRouter.ts | 1 - 6 files changed, 13 insertions(+), 26 deletions(-) diff --git a/client/src/app/retreat/admin/carpooling/page.tsx b/client/src/app/retreat/admin/carpooling/page.tsx index b9fbb98..8578b69 100644 --- a/client/src/app/retreat/admin/carpooling/page.tsx +++ b/client/src/app/retreat/admin/carpooling/page.tsx @@ -24,7 +24,7 @@ function Carpooling() { const [shiftPosition, setShiftPosition] = useState({ x: 0, y: 0 }) const [isShowUserInfo, setIsShowUserInfo] = useState(false) const [showRetreatAttendInfo, setShowRetreatAttendInfo] = useState( - {} as RetreatAttend + {} as RetreatAttend, ) function onMouseMove(event: MouseEvent) { @@ -91,16 +91,16 @@ function Carpooling() { data.sort( (a, b) => 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/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/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/newcomer/newcomerRouter.ts b/server/src/routes/newcomer/newcomerRouter.ts index 01d9a05..5cf2b63 100644 --- a/server/src/routes/newcomer/newcomerRouter.ts +++ b/server/src/routes/newcomer/newcomerRouter.ts @@ -204,9 +204,6 @@ router.get("/education", async (req, res) => { .orderBy("schedule.date", "DESC") .getMany() - console.log("eightWeeksAgoStr:", eightWeeksAgoStr) - console.log("recentSchedules count:", recentSchedules.length) - // 날짜별 스케줄 맵 생성 const worshipScheduleMap: Record = {} const sortedDates: string[] = [] @@ -221,8 +218,6 @@ router.get("/education", async (req, res) => { } }) - console.log("sortedDates:", sortedDates) - // 테이블 형식으로 변환: 각 새신자별로 날짜 → 강의 타입 매핑 const educationTable = newcomers.map((newcomer) => { // 날짜별 교육 기록 맵핑 diff --git a/server/src/routes/soon/soonRouter.ts b/server/src/routes/soon/soonRouter.ts index c095a80..17be4a0 100644 --- a/server/src/routes/soon/soonRouter.ts +++ b/server/src/routes/soon/soonRouter.ts @@ -283,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 From 705cc9611e1032248c630d41ba19d072d0856bbe Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 9 Feb 2026 20:25:04 +0900 Subject: [PATCH 21/21] =?UTF-8?q?update:=20=EC=83=88=EA=B0=80=EC=A1=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/app/admin/page.tsx | 10 +- .../app/leader/newcomer/management/page.tsx | 15 ++- server/src/routes/admin/dashboard.ts | 76 ++++++++------ server/src/routes/newcomer/newcomerRouter.ts | 99 +++++++++++++++++++ 4 files changed, 160 insertions(+), 40 deletions(-) diff --git a/client/src/app/admin/page.tsx b/client/src/app/admin/page.tsx index 3db4cd4..15fe4a1 100644 --- a/client/src/app/admin/page.tsx +++ b/client/src/app/admin/page.tsx @@ -34,7 +34,7 @@ interface DashboardData { attendPercent: number genderRatio: { male: number; female: number } genderCount: { male: number; female: number } - newFamilyPercent: number + newFamilyRegistrants: number } monthly: { attendCount: number @@ -43,7 +43,7 @@ interface DashboardData { attendPercent: number genderRatio: { male: number; female: number } genderCount: { male: number; female: number } - newFamilyPercent: number + newFamilyRegistrants: number } last4Weeks: Array<{ date: string @@ -53,7 +53,7 @@ interface DashboardData { attendPercent: number genderRatio: { male: number; female: number } genderCount: { male: number; female: number } - newFamilyPercent: number + newFamilyRegistrants: number }> } recentAbsentees: Array<{ @@ -223,10 +223,10 @@ function index() { - {dashboardData.statistics.weekly.newFamilyPercent}% + {dashboardData.statistics.weekly.newFamilyRegistrants}명 - 새가족 비율 + 새가족 등록자 diff --git a/client/src/app/leader/newcomer/management/page.tsx b/client/src/app/leader/newcomer/management/page.tsx index fac64d4..8ce3ad0 100644 --- a/client/src/app/leader/newcomer/management/page.tsx +++ b/client/src/app/leader/newcomer/management/page.tsx @@ -90,8 +90,16 @@ export default function NewcomerManagement() { async function saveData() { try { if (selectedNewcomer.id) { - // TODO: 수정 API 구현 필요 - // await axios.put(`/newcomer/${selectedNewcomer.id}`, selectedNewcomer) + 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", { @@ -113,8 +121,7 @@ export default function NewcomerManagement() { async function deleteNewcomer() { if (selectedNewcomer.id && confirm("정말로 삭제하시겠습니까?")) { try { - // TODO: 삭제 API 구현 필요 - // await axios.delete(`/newcomer/${selectedNewcomer.id}`) + await axios.delete(`/newcomer/${selectedNewcomer.id}`) setNotificationMessage("새신자가 삭제되었습니다.") clearSelectedNewcomer() await fetchData() 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/newcomer/newcomerRouter.ts b/server/src/routes/newcomer/newcomerRouter.ts index 5cf2b63..00ac19f 100644 --- a/server/src/routes/newcomer/newcomerRouter.ts +++ b/server/src/routes/newcomer/newcomerRouter.ts @@ -96,6 +96,105 @@ router.post("/", async (req, res) => { } }) +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)