From 0c658ee5dad9ce9bdc79dacf8d3fb56f115aab1b Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 13 Jun 2025 15:38:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=95=A1=EC=85=98=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/[userUnqId]/page.tsx | 10 ++ apps/cs/src/entities/user/index.ts | 4 + .../src/entities/user/model/user-interface.ts | 56 +++--- apps/cs/src/pages/users/index.ts | 1 + .../pages/users/ui/password-reset-dialog.tsx | 122 +++++++++++++ apps/cs/src/pages/users/ui/user-detail.tsx | 169 ++++++++++++++++++ apps/cs/src/pages/users/ui/users-form.tsx | 114 ++++++------ apps/cs/src/pages/users/ui/users-page.tsx | 10 +- 8 files changed, 400 insertions(+), 86 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx create mode 100644 apps/cs/src/pages/users/ui/password-reset-dialog.tsx create mode 100644 apps/cs/src/pages/users/ui/user-detail.tsx diff --git a/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx new file mode 100644 index 000000000..d4e0445ec --- /dev/null +++ b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx @@ -0,0 +1,10 @@ +import { UserDetail as VUserDetail } from "@/pages/users"; + +export default async function UserDetail({ + params, +}: { + params: Promise<{ userUnqId: string }>; +}) { + const { userUnqId } = await params; + return ; +} diff --git a/apps/cs/src/entities/user/index.ts b/apps/cs/src/entities/user/index.ts index 947d7375b..bd3627f71 100644 --- a/apps/cs/src/entities/user/index.ts +++ b/apps/cs/src/entities/user/index.ts @@ -1,7 +1,11 @@ export { UserApi } from "./api/user-service"; export { + USER_STATE_CODE, userSaveSchema, + userPasswordUpdateSchema, type UserSearchParams, type UserListResponse, type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, } from "./model/user-interface"; diff --git a/apps/cs/src/entities/user/model/user-interface.ts b/apps/cs/src/entities/user/model/user-interface.ts index d1f2c664f..abc48f9ae 100644 --- a/apps/cs/src/entities/user/model/user-interface.ts +++ b/apps/cs/src/entities/user/model/user-interface.ts @@ -1,6 +1,16 @@ import { z } from "zod"; import type { PageableList, PageableParams } from "@/shared/model"; +export const USER_STATE_CODE = { + 대기: "00", + 정상: "01", + 정지: "07", + 탈퇴: "08", + 삭제: "09", +} as const; + +type UserStateCode = (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; + // 사용자 검색 파라미터 export interface UserSearchParams extends PageableParams { keywordType?: string; @@ -19,7 +29,7 @@ export interface UserListResponse { email: string; roleId: string; roleName: string; - userStateCode: string; + userStateCode: UserStateCode; userStateCodeName: string; lastLoginDate: string; loginFailCount: number; @@ -49,7 +59,7 @@ export interface UserDetailResponse { email: string; encryptedPassword: string; refreshToken: string; - userStateCode: string; + userStateCode: UserStateCode; lastLoginDate: string; loginFailCount: number; pswdHnt: string; @@ -75,12 +85,14 @@ export interface UserDetailResponse { instSeCd: string; aprvYmd: string; enoCd: string; + dtlInstCd: string; laeSeNm: string; ogdpSeNm: string; instSeNm: string; userStateNm: string; pswdHntNm: string; ecoNm: string; + dtlInstNm: string; } // 사용자 상태 통계 응답 인터페이스 @@ -123,29 +135,23 @@ export const userUpdateSchema = z.object({ }); // 사용자 비밀번호 변경 스키마 -export const userPasswordUpdateSchema = z.object({ - password: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ) - .optional(), - provider: z.string().optional(), - token: z.string().optional(), - newPassword: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), - newPasswordConfirm: z - .string() - .regex( - /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, - "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", - ), -}); +export const userPasswordUpdateSchema = z + .object({ + password: z.string().optional(), + provider: z.string().optional(), + token: z.string().optional(), + newPassword: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + newPasswordConfirm: z.string(), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: "비밀번호가 일치하지 않습니다.", + path: ["newPasswordConfirm"], + }); // 사용자 비밀번호 찾기 변경 스키마 export const userFindPasswordUpdateSchema = z.object({ diff --git a/apps/cs/src/pages/users/index.ts b/apps/cs/src/pages/users/index.ts index f0c736dea..e518fb440 100644 --- a/apps/cs/src/pages/users/index.ts +++ b/apps/cs/src/pages/users/index.ts @@ -1,2 +1,3 @@ export { UsersPage } from "./ui/users-page"; export { CreatePage } from "./ui/create-page"; +export { UserDetail } from "./ui/user-detail"; diff --git a/apps/cs/src/pages/users/ui/password-reset-dialog.tsx b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..102358e55 --- /dev/null +++ b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + DialogTrigger, +} from "@common/components"; +import { + UserApi, + userPasswordUpdateSchema, + type UserPasswordUpdateData, +} from "@/entities/user"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/ui/form"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; + +interface PasswordResetDialogProps { + userUnqId: string; +} + +export function PasswordResetDialog({ userUnqId }: PasswordResetDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다."); + setOpen(false); + form.reset(); + }, + }); + + return ( + + + + + + + 비밀번호 초기화 + +
+ onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/cs/src/pages/users/ui/user-detail.tsx b/apps/cs/src/pages/users/ui/user-detail.tsx new file mode 100644 index 000000000..4513555c2 --- /dev/null +++ b/apps/cs/src/pages/users/ui/user-detail.tsx @@ -0,0 +1,169 @@ +"use client"; + +import dayjs from "dayjs"; +import { useRouter } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Button, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { USER_STATE_CODE, UserApi } from "@/entities/user"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { PasswordResetDialog } from "./password-reset-dialog"; + +function UserDetail({ userUnqId }: { userUnqId: string }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const alert = useAlertStore((state) => state.setMessage); + + const { data } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + queryFn: () => UserApi.findByUserUnqId(userUnqId), + enabled: !!userUnqId, + }); + + const { mutate: onLockUser } = useMutationWrapper({ + mutationFn: () => UserApi.lockUser(userUnqId), + onSuccess: () => { + alert("계정 잠금이 활성화 되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + }); + }, + }); + + const { mutate: onUnlockUser } = useMutationWrapper({ + mutationFn: () => UserApi.unlockUser(userUnqId), + onSuccess: () => { + alert("계정 잠금이 해제되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + }); + }, + }); + + const { mutate: onApplyUser } = useMutationWrapper({ + mutationFn: () => UserApi.applyUser(userUnqId), + onSuccess: () => { + alert("사용자 승인이 완료되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + }); + }, + }); + + return ( +
+
+

회원 정보

+
+
+
+ {data?.userStateCode === USER_STATE_CODE.정지 ? ( + + ) : ( + + )} + + {data?.userStateCode === USER_STATE_CODE.대기 && ( + + )} +
+ + + + + + + + 아이디 + {data?.userId} + + + 이름 + {data?.userName} + + + 연락처 + 휴대폰: {data?.mphn} + + + 전화번호: {data?.telno} + + + 이메일 + {data?.email} + + + 주소 + + {data?.zip && `(${data?.zip})`} {data?.addr} + + + + {data?.daddr} + + + 기관구분 + {data?.ogdpSeNm} + + + 세부기관 + {data?.dtlInstNm} + + + 소속 + {data?.laeSeNm} + + + 환경청 + {data?.ecoNm} + + + 가입일 + + {data?.regDt ? dayjs(data?.regDt).format("YYYY-MM-DD") : ""} + + + + 최근로그인 + + {data?.lastLoginDate + ? dayjs(data?.lastLoginDate).format("YYYY-MM-DD HH:mm:ss") + : ""} + + + + 계정상태 + {data?.userStateNm} + + +
+
+
+ + + +
+
+ ); +} + +export { UserDetail }; diff --git a/apps/cs/src/pages/users/ui/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx index 9c7527f16..ae575f317 100644 --- a/apps/cs/src/pages/users/ui/users-form.tsx +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useWatch, type UseFormReturn } from "react-hook-form"; import { useQuery } from "@tanstack/react-query"; import { @@ -47,8 +47,6 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { const [isValidUserId, setIsValidUserId] = useState(false); const [isValidEmail, setIsValidEmail] = useState(false); - const [password, setPassword] = useState(""); - const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordMatch, setPasswordMatch] = useState(true); const [instSeCdNm, setInstSeCdNm] = useState(""); @@ -86,68 +84,73 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { }, }); - const getAuthSeCdSelectData = () => { + const authSeCdSelectData = useMemo(() => { if (watchOgdpSeCd === "1") - return authSeCd?.filter( - (code) => - code.lwpoCd === "ROLE_USER1" || - code.lwpoCd === "ROLE_USER2" || - code.lwpoCd === "ROLE_USER3", + return ( + authSeCd?.filter( + (code) => + code.lwpoCd === "ROLE_USER1" || + code.lwpoCd === "ROLE_USER2" || + code.lwpoCd === "ROLE_USER3", + ) ?? [] ); if (watchOgdpSeCd === "3" || watchOgdpSeCd === "4") - return authSeCd?.filter( - (code) => code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5", + return ( + authSeCd?.filter( + (code) => + code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5", + ) ?? [] ); - }; + return []; + }, [authSeCd, watchOgdpSeCd]); - const getLaeSeCdSelectData = () => { + const laeSeCdSelectData = useMemo(() => { if (watchDtlInstCd === "ROLE_USER1") - return laeSeCd?.filter((code) => code.lwpoCd === "G102"); + return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? []; if (watchDtlInstCd === "ROLE_USER2") - return laeSeCd?.filter((code) => code.lwpoCd === "G101"); + return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? []; if (watchDtlInstCd === "ROLE_USER3") - return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")); + return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? []; if (watchDtlInstCd === "ROLE_USER4") - return laeSeCd?.filter( - (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, + return ( + laeSeCd?.filter( + (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, + ) ?? [] ); if (watchDtlInstCd === "ROLE_USER5") - return laeSeCd?.filter( - (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7, + return ( + laeSeCd?.filter( + (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7, + ) ?? [] ); - }; + return []; + }, [laeSeCd, watchDtlInstCd]); - const getEnoCdSelectData = () => { - return laeSeCd?.filter((code) => code.unit?.startsWith("G2")); - }; + const enoCdSelectData = useMemo( + () => laeSeCd?.filter((code) => code.unit?.startsWith("G2")) ?? [], + [laeSeCd], + ); - const handlePasswordChange = (value: string) => { - setPassword(value); - if (passwordConfirm && value !== passwordConfirm) { - setPasswordMatch(false); - } else { - setPasswordMatch(true); - } - }; + const handlePasswordConfirmChange = useCallback((value: string) => { + const password = form.getValues("password"); + setPasswordMatch(!password || value === password); + }, []); - const handlePasswordConfirmChange = (value: string) => { - setPasswordConfirm(value); - if (password && value !== password) { - setPasswordMatch(false); - } else { - setPasswordMatch(true); - } - }; + const handleAddressSelect = useCallback( + (data: { zip: string; addr: string }) => { + form.setValue("zip", data.zip); + form.setValue("addr", data.addr); + }, + [form], + ); - const handleAddressSelect = (data: { zip: string; addr: string }) => { - form.setValue("zip", data.zip); - form.setValue("addr", data.addr); - }; - - const handleAgencySelect = (data: { lwpoCd: string; lwpoCdNm: string }) => { - form.setValue("instSeCd", data.lwpoCd); - setInstSeCdNm(data.lwpoCdNm); - }; + const handleAgencySelect = useCallback( + (data: { lwpoCd: string; lwpoCdNm: string }) => { + form.setValue("instSeCd", data.lwpoCd); + setInstSeCdNm(data.lwpoCdNm); + }, + [form], + ); return ( @@ -216,10 +219,6 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { {...field} type="password" value={field.value ?? ""} - onChange={(e) => { - field.onChange(e); - handlePasswordChange(e.target.value); - }} className="w-[24rem] !text-[1.3rem]" placeholder="비밀번호를 입력하세요" /> @@ -238,14 +237,13 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
handlePasswordConfirmChange(e.target.value) } className="w-[24rem] !text-[1.3rem]" placeholder="비밀번호를 다시 입력하세요" /> - {!passwordMatch && passwordConfirm && ( + {!passwordMatch && (

비밀번호가 일치하지 않습니다.

@@ -526,7 +524,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - {getAuthSeCdSelectData()?.map((code) => ( + {authSeCdSelectData?.map((code) => ( {code.lwpoCdNm} @@ -561,7 +559,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - {getLaeSeCdSelectData()?.map((code) => ( + {laeSeCdSelectData?.map((code) => ( {code.lwpoCdNm} @@ -599,7 +597,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - {getEnoCdSelectData()?.map((code) => ( + {enoCdSelectData?.map((code) => ( {code.lwpoCdNm} diff --git a/apps/cs/src/pages/users/ui/users-page.tsx b/apps/cs/src/pages/users/ui/users-page.tsx index 434dd7e7c..956a3434c 100644 --- a/apps/cs/src/pages/users/ui/users-page.tsx +++ b/apps/cs/src/pages/users/ui/users-page.tsx @@ -24,7 +24,11 @@ import { SelectValue, } from "@common/components"; import { CodeApi } from "@/entities/code"; -import { UserApi, type UserSearchParams } from "@/entities/user"; +import { + UserApi, + type UserListResponse, + type UserSearchParams, +} from "@/entities/user"; import { ENDPOINT } from "@/shared/config"; import { columns } from "../model/table-columns"; @@ -92,8 +96,8 @@ function UsersPage() { refetch(); }; - const handleRowClick = (row: unknown) => { - console.log(row); + const handleRowClick = (row: UserListResponse) => { + router.push(`/users/${row.userUnqId}`); }; const handlePresetDateRange = (value: string) => {