diff --git a/apps/cm/.env.local b/apps/cm/.env.local index 896025bd6..a544bdf7e 100644 --- a/apps/cm/.env.local +++ b/apps/cm/.env.local @@ -3,6 +3,6 @@ NEXT_PUBLIC_PORT=3000 NEXT_PUBLIC_PROXY_HOST=http://localhost:3000 NEXT_PUBLIC_SITE_ID=CS NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000 -NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000 +NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000 NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014 NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file diff --git a/apps/cm/app/api/auth/logout/route.ts b/apps/cm/app/auth/logout/route.ts similarity index 100% rename from apps/cm/app/api/auth/logout/route.ts rename to apps/cm/app/auth/logout/route.ts diff --git a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx index c0be4c2dd..1aa4f8e61 100644 --- a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx @@ -13,6 +13,11 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { const { userInfo, setUserInfo } = useUserStore((state) => state); async function authCheck() { + // 로그인 페이지에서는 인증 체크를 건너뜀 + if (window.location.pathname === '/auth/login') { + return; + } + const userId = getCookie(AUTH_USER_ID); if (!userId) { throw new Error("Missing required authentication tokens"); diff --git a/apps/cm/src/entities/code/@x/posting.ts b/apps/cm/src/entities/code/@x/posting.ts new file mode 100644 index 000000000..0525684d9 --- /dev/null +++ b/apps/cm/src/entities/code/@x/posting.ts @@ -0,0 +1 @@ +export { CodeSelect } from "../ui/code-select"; diff --git a/apps/cm/src/entities/code/api/code-service.ts b/apps/cm/src/entities/code/api/code-service.ts new file mode 100644 index 000000000..0d1055f6a --- /dev/null +++ b/apps/cm/src/entities/code/api/code-service.ts @@ -0,0 +1,14 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { CodeService, ICode } from "../model/code-interface"; + +export class CodeApiService implements CodeService { + public async getCode(upCd: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`, + ); + return response.data; + } +} + +export const CodeApi = new CodeApiService(); diff --git a/apps/cm/src/entities/code/api/lwpocode-service.ts b/apps/cm/src/entities/code/api/lwpocode-service.ts new file mode 100644 index 000000000..818ddb120 --- /dev/null +++ b/apps/cm/src/entities/code/api/lwpocode-service.ts @@ -0,0 +1,73 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + LwpoCodeFormSchema, + type LwpoCodeService, + type LwpoCodeList, + type LwpoCodeDetail, + type LwpoCodeParams, + type LwpoCodeFormData, +} from "../model/lwpocode-interface"; + +export class LwpoCodeApiService implements LwpoCodeService { + public async getLwpoCodeList(params?: LwpoCodeParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}`, + { params }, + ); + return response.data; + } + + public async getUpCodeLwpoCodeList(upCd: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/up-cd/${upCd}`, + ); + return response.data; + } + + public async lwpoCodeDetail( + upCd: string, + lwpoCd: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`, + ); + return response.data; + } + + public async createLwpoCode(data: LwpoCodeFormData): Promise { + const validatedData = LwpoCodeFormSchema.parse(data); + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}`, + validatedData, + ); + return response.data; + } + + public async updateLwpoCode( + upCd: string, + lwpoCd: string, + data: LwpoCodeFormData, + ): Promise { + const validatedData = LwpoCodeFormSchema.parse(data); + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`, + validatedData, + ); + return response.data; + } + + public async deleteLwpoCode(upCd: string, lwpoCd: string): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`, + ); + } + + public async codeRefreshAll(): Promise { + await client.post( + `${ENDPOINT.CMS_SERVICE.LWPO_CODE}/cache/refresh-all`, + ); + } +} + +export const LwpoCodeApi = new LwpoCodeApiService(); diff --git a/apps/cm/src/entities/code/api/upcode-service.ts b/apps/cm/src/entities/code/api/upcode-service.ts new file mode 100644 index 000000000..dcccd9534 --- /dev/null +++ b/apps/cm/src/entities/code/api/upcode-service.ts @@ -0,0 +1,47 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + upCodeFormSchema, + type UpCodeService, + type UpCodeList, + type UpCodeDetail, + type UpCodeParams, + type UpCodeFormData, +} from "../model/upcode-interface"; + +export class UpCodeApiService implements UpCodeService { + public async getUpCodeList(params?: UpCodeParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.UP_CODE}`, + { params }, + ); + return response.data; + } + + public async upCodeDetail(upCd: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`, + ); + return response.data; + } + + public async createUpCode(data: UpCodeFormData): Promise { + const validatedData = upCodeFormSchema.parse(data); + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.UP_CODE}`, + validatedData, + ); + return response.data; + } + + public async updateUpCode(upCd: string, data: UpCodeFormData): Promise { + const validatedData = upCodeFormSchema.parse(data); + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`, + validatedData, + ); + return response.data; + } +} + +export const UpCodeApi = new UpCodeApiService(); diff --git a/apps/cm/src/entities/code/index.ts b/apps/cm/src/entities/code/index.ts new file mode 100644 index 000000000..e166e2c29 --- /dev/null +++ b/apps/cm/src/entities/code/index.ts @@ -0,0 +1,19 @@ +export { CodeApi } from "./api/code-service"; +export type { ICode } from "./model/code-interface"; +export { CodeSelect } from "./ui/code-select"; + +export { UpCodeApi } from "./api/upcode-service"; +export { + upCodeFormSchema, + type UpCode, + type UpCodeFormData, + type UpCodeParams, +} from "./model/upcode-interface"; + +export { LwpoCodeApi } from "./api/lwpocode-service"; +export { + LwpoCodeFormSchema, + type LwpoCode, + type LwpoCodeFormData, + type LwpoCodeParams, +} from "./model/lwpocode-interface"; diff --git a/apps/cm/src/entities/code/model/code-interface.ts b/apps/cm/src/entities/code/model/code-interface.ts new file mode 100644 index 000000000..4562e2559 --- /dev/null +++ b/apps/cm/src/entities/code/model/code-interface.ts @@ -0,0 +1,10 @@ +export interface ICode { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + unit: string; +} + +export interface CodeService { + getCode(upCd: string): Promise; +} diff --git a/apps/cm/src/entities/code/model/lwpocode-interface.ts b/apps/cm/src/entities/code/model/lwpocode-interface.ts new file mode 100644 index 000000000..2538c1c30 --- /dev/null +++ b/apps/cm/src/entities/code/model/lwpocode-interface.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import type { PageableParams, PageableList } from "@/shared/model"; + +export interface LwpoCodeParams extends PageableParams { + upCd?: string; + lwpoCd?: string; + lwpoCdNm?: string; + useYn?: string; +} + +export interface LwpoCode { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + lwpoCdExpln: string; + unit: string; + prntLwpoCd: string; + useYn: string; + sortSeq: number; + regDt: string; + rgTr: string; + rgTrNm: string; + mdfcnDt: string; + mdFr: string; +} + +export interface LwpoCodeDetail extends LwpoCodeFormData { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + lwpoCdExpln: string; + unit: string; + prntLwpoCd: string; + useYn: string; + sortSeq: 0; +} + +export const LwpoCodeFormSchema = z.object({ + lwpoCd: z.string().max(10).nonempty(), + upCd: z.string().max(10).nonempty(), + lwpoCdNm: z.string().max(100).nonempty(), + lwpoCdExpln: z.string().max(1000).nullish(), + unit: z.string().max(20).nullish(), + prntLwpoCd: z.string().max(10).nullish(), + useYn: z.string(), + sortSeq: z.number(), + delYn: z.string(), +}); + +export type LwpoCodeFormData = z.infer; +export type LwpoCodeList = PageableList; + +export interface LwpoCodeService { + getLwpoCodeList(params: LwpoCodeParams): Promise; + getUpCodeLwpoCodeList(upCd: string): Promise; + lwpoCodeDetail(upCd: string, lwpoCd: string): Promise; + updateLwpoCode( + upCd: string, + lwpoCd: string, + data: LwpoCodeFormData, + ): Promise; + createLwpoCode(data: LwpoCodeFormData): Promise; + deleteLwpoCode( + upCd: string, + lwpoCd: string, + data: LwpoCodeFormData, + ): Promise; + codeRefreshAll(): Promise; +} diff --git a/apps/cm/src/entities/code/model/upcode-interface.ts b/apps/cm/src/entities/code/model/upcode-interface.ts new file mode 100644 index 000000000..73bf4f3ae --- /dev/null +++ b/apps/cm/src/entities/code/model/upcode-interface.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import type { PageableParams, PageableList } from "@/shared/model"; + +export interface UpCodeParams extends PageableParams { + keyword?: string; + upCd?: string; + cdSe?: string; + useYn?: string; + siteId?: string; +} + +export interface UpCode { + upCd: string; + upCdNm: string; + upCdExpln: string; + cdSe: string; + siteId: string; + useYn: string; + regDt: string; +} + +export interface UpCodeDetail extends UpCodeFormData { + upCd: string; + upCdNm: string; + upCdExpln: string; + cdSe: string; + siteId: string; + useYn: string; + delYn: string; + regDt: string; + rgTrNm: string; +} + +export const upCodeFormSchema = z.object({ + upCd: z.string().max(10).nonempty(), + upCdNm: z.string().max(100).nonempty(), + upCdExpln: z.string().max(1000).nullish(), + cdSe: z.string().max(10).nonempty(), + siteId: z.string().max(2).nullish(), + useYn: z.string(), + delYn: z.string(), +}); + +export type UpCodeFormData = z.infer; +export type UpCodeList = PageableList; + +export interface UpCodeService { + getUpCodeList(params: UpCodeParams): Promise; + upCodeDetail(upCodeId: string): Promise; + updateUpCode(upCodeId: string, data: UpCodeFormData): Promise; + createUpCode(data: UpCodeFormData): Promise; +} diff --git a/apps/cm/src/entities/code/ui/code-select.tsx b/apps/cm/src/entities/code/ui/code-select.tsx new file mode 100644 index 000000000..08df4ce51 --- /dev/null +++ b/apps/cm/src/entities/code/ui/code-select.tsx @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { + cn, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { CodeApi } from "../api/code-service"; + +type CodeSelectProps = { + upCd: string; + placeholder?: string; + className?: string; +} & React.ComponentPropsWithoutRef; + +function CodeSelect({ + upCd, + placeholder = "선택", + className, + ...props +}: CodeSelectProps) { + const { data: code } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd], + queryFn: () => CodeApi.getCode(upCd), + enabled: !!upCd, + }); + + return ( + + ); +} + +export { CodeSelect }; diff --git a/apps/cm/src/entities/user/api/user-service.ts b/apps/cm/src/entities/user/api/user-service.ts new file mode 100644 index 000000000..482d51e16 --- /dev/null +++ b/apps/cm/src/entities/user/api/user-service.ts @@ -0,0 +1,225 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + UserService, + UserSearchParams, + UserList, + UserDetailResponse, + UserSttsStatsList, + UserUpdateData, + UserPasswordUpdateData, + UserFindPasswordUpdateData, + UserPasswordMatchData, + UserUpdateInfoData, + UserSaveData, + UserFindPasswordSaveData, + UserVerifyData, + UserEmailData, + UserIdData, + AdminUserList, +} from "../model/user-interface"; + +export class UserApiService implements UserService { + // 사용자 목록 조회 + public async findPageUser(params: UserSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}`, + { params }, + ); + return response.data; + } + + // 사용자 단건 조회 + public async findByUserUnqId(userUnqId: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + ); + return response.data; + } + + // 로그인한 사용자 정보 조회 + public async findByUserUnqIdByLogin(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + return response.data; + } + + // 사용자 회원상태 통계 조회 + public async selectUserSttsStats(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/stts/stats`, + ); + return response.data; + } + + // 사용자 저장 + public async saveUser(data: UserSaveData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}`, + data, + ); + return response.data; + } + + // 사용자 수정 + public async updateUser( + userUnqId: string, + data: UserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 삭제 + public async deleteUser(userUnqId: string): Promise { + const response = await client.delete( + `${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 처리 + public async lockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 해제처리 + public async unlockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`, + ); + return response.data; + } + + // 리프레시 토큰 정보 재발급 + public async refreshToken(): Promise { + await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`); + } + + // 사용자 비밀번호 변경 + public async updatePassword(data: UserPasswordUpdateData): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/update`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 초기화(관리자용) + public async initializePassword( + userUnqId: string, + data: UserPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 변경 + public async changePassword( + data: UserFindPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/change`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 확인 + public async matchPassword(data: UserPasswordMatchData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/match`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 + public async findPasswordUser( + data: UserFindPasswordSaveData, + ): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/find`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 유효성 확인 + public async validPassword(token: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`, + ); + return response.data; + } + + // 사용자 회원정보 변경 + public async updateInfo( + userUnqId: string, + data: UserUpdateInfoData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 승인처리 + public async applyUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`, + ); + return response.data; + } + + // 사용자 회원탈퇴 + public async leaveUser(data: UserVerifyData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/leave`, + data, + ); + return response.data; + } + + // 사용자 이메일 중복 체크 + public async existsEmail(data: UserEmailData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + data, + ); + return response.data; + } + + // 사용자 아이디 중복 체크 + public async existsUserId(data: UserIdData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + data, + ); + return response.data; + } + + // 관리자 목록 조회 + public async findPageAdminUser( + params: UserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/admin/list`, + { params }, + ); + return response.data; + } +} + +export const UserApi = new UserApiService(); diff --git a/apps/cm/src/entities/user/index.ts b/apps/cm/src/entities/user/index.ts new file mode 100644 index 000000000..8927256f9 --- /dev/null +++ b/apps/cm/src/entities/user/index.ts @@ -0,0 +1,17 @@ +export { UserApi } from "./api/user-service"; +export { + USER_STATE_CODE, + userSaveSchema, + userPasswordUpdateSchema, + userUpdateSchema, + type UserStateCode, + type UserSearchParams, + type UserListResponse, + type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, + type UserUpdateData, + type AdminUserList, + type AdminUserListResponse, +} from "./model/user-interface"; +export { PasswordResetDialog } from "./ui/password-reset-dialog"; diff --git a/apps/cm/src/entities/user/model/user-interface.ts b/apps/cm/src/entities/user/model/user-interface.ts new file mode 100644 index 000000000..012fe9e92 --- /dev/null +++ b/apps/cm/src/entities/user/model/user-interface.ts @@ -0,0 +1,362 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export const USER_STATE_CODE = { + 대기: "00", + 정상: "01", + 정지: "07", + 탈퇴: "08", + 삭제: "09", +} as const; + +export type UserStateCode = + (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; + +// 사용자 검색 파라미터 +export interface UserSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + userStateCode?: string; + aprvYn?: string; + grpId?: string; + frDateYmd?: string; + toDateYmd?: string; + userName?: string; +} + +// 사용자 목록 응답 인터페이스 +export interface UserListResponse { + userId: string; + userName: string; + email: string; + roleId: string; + roleName: string; + userStateCode: UserStateCode; + userStateCodeName: string; + lastLoginDate: string; + loginFailCount: number; + userUnqId: string; + mphn: string; + telno: string; + aprvYn: string; + regDt: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + aprvYmd: string; +} + +// 사용자 상세 응답 인터페이스 +export interface UserDetailResponse { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: UserStateCode; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; + twoFactorEnabled: boolean; + adminRoleYn: string; + instSeCd: string; + aprvYmd: string; + enoCd: string; + dtlInstCd: string; + laeSeNm: string; + ogdpSeNm: string; + instSeNm: string; + userStateNm: string; + pswdHntNm: string; + ecoNm: string; + dtlInstNm: string; +} + +// 사용자 상태 통계 응답 인터페이스 +export interface UserSttsStatsResponse { + sttsCd: string; + sttsNm: string; + cnt: number; +} + +// 관리자 목록 응답 인터페이스 +export interface AdminUserListResponse { + userUnqId: string; + userId: string; + userName: string; + groupId: string; + groupNm: string; + userStateCode: string; + userStateCodeName: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + dtlInstCd: string; + dtlInstNm: string; + mdfr: string; + mdfrNm: string; + mdfcnDt: string; +} + +// 사용자 수정 요청 스키마 +export const userUpdateSchema = z.object({ + userId: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + roleId: z.string().optional(), + userStateCode: z.string(), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + aprvYn: z.string(), + autzrId: z.string().uuid().optional(), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + aprvYmd: z.string().max(8).optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 변경 스키마 +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({ + tokenValue: z.string(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 비밀번호 확인 스키마 +export const userPasswordMatchSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 회원정보 변경 스키마 +export const userUpdateInfoSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), +}); + +// 사용자 저장 스키마 +export const userSaveSchema = z.object({ + userId: z.string(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 찾기 저장 스키마 +export const userFindPasswordSaveSchema = z.object({ + userName: z.string(), + emailAddr: z.string().email(), + mainUrl: z.string(), + changePasswordUrl: z.string(), +}); + +// 사용자 인증 요청 스키마 +export const userVerifySchema = z.object({ + password: z + .string() + .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), +}); + +// 사용자 이메일 요청 스키마 +export const userEmailSchema = z.object({ + userId: z.string().optional(), + email: z.string().email(), +}); + +// 사용자 ID 요청 스키마 +export const userIdSchema = z.object({ + userId: z.string(), +}); + +// Zod 스키마 타입 정의 +export type UserUpdateData = z.infer; +export type UserPasswordUpdateData = z.infer; +export type UserFindPasswordUpdateData = z.infer< + typeof userFindPasswordUpdateSchema +>; +export type UserPasswordMatchData = z.infer; +export type UserUpdateInfoData = z.infer; +export type UserSaveData = z.infer; +export type UserFindPasswordSaveData = z.infer< + typeof userFindPasswordSaveSchema +>; +export type UserVerifyData = z.infer; +export type UserEmailData = z.infer; +export type UserIdData = z.infer; + +// 페이지네이션 타입 정의 +export type UserList = PageableList; +export type UserSttsStatsList = UserSttsStatsResponse[]; +export type AdminUserList = PageableList; + +// 사용자 서비스 인터페이스 +export interface UserService { + // 사용자 목록 조회 + findPageUser: (params: UserSearchParams) => Promise; + + // 사용자 단건 조회 + findByUserUnqId: (userUnqId: string) => Promise; + + // 로그인한 사용자 정보 조회 + findByUserUnqIdByLogin: () => Promise; + + // 사용자 회원상태 통계 조회 + selectUserSttsStats: () => Promise; + + // 사용자 저장 + saveUser: (data: UserSaveData) => Promise; + + // 사용자 수정 + updateUser: (userUnqId: string, data: UserUpdateData) => Promise; + + // 사용자 삭제 + deleteUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 처리 + lockUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 해제처리 + unlockUser: (userUnqId: string) => Promise; + + // 리프레시 토큰 정보 재발급 + refreshToken: () => Promise; + + // 사용자 비밀번호 변경 + updatePassword: (data: UserPasswordUpdateData) => Promise; + + // 사용자 비밀번호 초기화(관리자용) + initializePassword: ( + userUnqId: string, + data: UserPasswordUpdateData, + ) => Promise; + + // 사용자 비밀번호 찾기 변경 + changePassword: (data: UserFindPasswordUpdateData) => Promise; + + // 사용자 비밀번호 확인 + matchPassword: (data: UserPasswordMatchData) => Promise; + + // 사용자 비밀번호 찾기 + findPasswordUser: (data: UserFindPasswordSaveData) => Promise; + + // 사용자 비밀번호 찾기 유효성 확인 + validPassword: (token: string) => Promise; + + // 사용자 회원정보 변경 + updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise; + + // 사용자 승인처리 + applyUser: (userUnqId: string) => Promise; + + // 사용자 회원탈퇴 + leaveUser: (data: UserVerifyData) => Promise; + + // 사용자 이메일 중복 체크 + existsEmail: (data: UserEmailData) => Promise; + + // 사용자 아이디 중복 체크 + existsUserId: (data: UserIdData) => Promise; + + // 관리자 목록 조회 + findPageAdminUser: (params: UserSearchParams) => Promise; +} diff --git a/apps/cm/src/entities/user/ui/password-reset-dialog.tsx b/apps/cm/src/entities/user/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..0d6aaf94e --- /dev/null +++ b/apps/cm/src/entities/user/ui/password-reset-dialog.tsx @@ -0,0 +1,124 @@ +"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; + children: React.ReactNode; +} + +export function PasswordResetDialog({ + userUnqId, + children, +}: PasswordResetDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + provider: "password", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다.", () => { + setOpen(false); + form.reset(); + }); + }, + }); + + return ( + + {children} + + + 비밀번호 초기화 + +
+ onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/cm/src/pages/auth/login/ui/SignupModal.tsx b/apps/cm/src/pages/auth/login/ui/SignupModal.tsx new file mode 100644 index 000000000..d61949ccc --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/SignupModal.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { UserSaveData } from "@/entities/user"; +import { ENDPOINT, BASE_URL } from "@/shared/config"; +import { SignupClient } from "@/shared/api/signup-client"; +import "./signup-modal.css"; + +interface SignupModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onClose: () => void; + onSuccess: () => void; +} + +function SignupModal({ open, onClose, onSuccess }: SignupModalProps) { + const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement'); + const [agreed, setAgreed] = useState(false); + const [formData, setFormData] = useState({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingId, setIsCheckingId] = useState(false); + const [isCheckingEmail, setIsCheckingEmail] = useState(false); + const [idAvailable, setIdAvailable] = useState(null); + const [emailAvailable, setEmailAvailable] = useState(null); + + // 모달이 닫힐 때 초기화 + useEffect(() => { + if (!open) { + setCurrentStep('agreement'); + setAgreed(false); + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } + }, [open]); + + const handleAgreementChange = (e: React.ChangeEvent) => { + setAgreed(e.target.checked); + }; + + const handleNextStep = () => { + if (agreed) { + setCurrentStep('signup'); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // 에러 초기화 + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: "" })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + // 필수 필드 검증 + if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; + if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; + if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; + if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; + if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요."; + if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; + if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; + + // 비밀번호 확인 + if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; + } + + // 비밀번호 정규식 검증 + const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; + if (formData.password && !passwordRegex.test(formData.password)) { + newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; + } + + // 이메일 형식 검증 + if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "올바른 이메일 형식을 입력해주세요."; + } + + // 중복 체크 검증 + if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다."; + if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다."; + + setErrors(newErrors); + const isValid = Object.keys(newErrors).length === 0; + console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); + return isValid; + }; + + const checkUserIdDuplicate = async () => { + if (!formData.userId) { + setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." })); + return; + } + + setIsCheckingId(true); + try { + // SignupClient를 통해 중복 체크 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + { userId: formData.userId } + ); + + const exists = response.data; + setIdAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." })); + } else { + setErrors(prev => ({ ...prev, userId: "" })); + } + } catch (error) { + console.error("아이디 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingId(false); + } + }; + + const checkEmailDuplicate = async () => { + if (!formData.email) { + setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." })); + return; + } + + setIsCheckingEmail(true); + try { + // SignupClient를 통해 중복 체크 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + { email: formData.email } + ); + + const exists = response.data; + setEmailAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." })); + } else { + setErrors(prev => ({ ...prev, email: "" })); + } + } catch (error) { + console.error("이메일 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingEmail(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + console.log("회원가입 버튼 클릭됨"); + + if (!validateForm()) { + console.log("폼 검증 실패"); + return; + } + + setIsLoading(true); + try { + const signupData: UserSaveData = { + userId: formData.userId, + userName: formData.userName, + email: formData.email || undefined, + password: formData.password, + pswdHnt: formData.pswdHnt, + pswdAns: formData.pswdAns || undefined, + mphn: formData.mphn || undefined, + telno: formData.telno || undefined, + zip: formData.zip || undefined, + addr: formData.addr || undefined, + daddr: formData.daddr || undefined, + laeSeCd: formData.laeSeCd, + ogdpSeCd: formData.ogdpSeCd, + expryYmd: "", + instSeCd: formData.instSeCd || undefined, + dtlInstCd: formData.dtlInstCd || undefined, + enoCd: formData.enoCd || undefined, + }; + + // SignupClient를 통해 익명사용자 회원가입 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`, + signupData + ); + + console.log("회원가입 성공:", response.data); + + // 성공 시 모달 닫기 및 콜백 호출 + onSuccess(); + onClose(); + + // 폼 초기화 + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + + } catch (error: unknown) { + console.error("회원가입 오류:", error); + if (error instanceof Error) { + setErrors(prev => ({ ...prev, submit: error.message })); + } else { + setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." })); + } + } finally { + setIsLoading(false); + } + }; + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}

+ +
+ + {currentStep === 'agreement' ? ( +
+
+

개인정보 수집 및 이용 동의

+

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

+ +

1. 수집하는 개인정보 항목

+
    +
  • 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분
  • +
  • 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호
  • +
+ +

2. 개인정보의 수집 및 이용목적

+
    +
  • 회원가입 및 관리
  • +
  • 서비스 제공 및 운영
  • +
  • 고객상담 및 문의응답
  • +
+ +

3. 개인정보의 보유 및 이용기간

+

회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)

+ +

4. 동의 거부권 및 거부 시 불이익

+

개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.

+
+ +
+ +
+ +
+ + +
+
+ ) : ( +
+
+

기본 정보

+ +
+
+ +
+ + +
+ {idAvailable === true && 사용 가능한 아이디입니다.} + {errors.userId && {errors.userId}} +
+
+ +
+
+ + + {errors.userName && {errors.userName}} +
+
+ +
+
+ +
+ + +
+ {emailAvailable === true && 사용 가능한 이메일입니다.} + {errors.email && {errors.email}} +
+
+ +
+
+ + + {errors.password && {errors.password}} +
+
+ +
+
+ + + {errors.confirmPassword && {errors.confirmPassword}} +
+
+ +
+
+ + + {errors.pswdHnt && {errors.pswdHnt}} +
+
+ +
+
+ + + {errors.pswdAns && {errors.pswdAns}} +
+
+
+ +
+

연락처 정보

+ +
+
+ + + {errors.mphn && {errors.mphn}} +
+
+ +
+
+ + + {errors.telno && {errors.telno}} +
+
+
+ +
+

주소 정보

+ +
+
+ + + {errors.zip && {errors.zip}} +
+
+ +
+
+ + + {errors.addr && {errors.addr}} +
+
+ +
+
+ + + {errors.daddr && {errors.daddr}} +
+
+
+ +
+

기관 정보

+ +
+
+ + + {errors.laeSeCd && {errors.laeSeCd}} +
+
+ +
+
+ + + {errors.ogdpSeCd && {errors.ogdpSeCd}} +
+
+ +
+
+ + + {errors.instSeCd && {errors.instSeCd}} +
+
+ +
+
+ + + {errors.dtlInstCd && {errors.dtlInstCd}} +
+
+
+ + {errors.submit && ( +
+
{errors.submit}
+
+ )} + +
+ + + +
+
+ )} +
+
+ ); +} + +export default SignupModal; diff --git a/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx new file mode 100644 index 000000000..06193951e --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx @@ -0,0 +1,776 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { SignupClient } from "@/shared/api/signup-client"; +import { BASE_URL, ENDPOINT } from "@/shared/config"; +import type { UserSaveData } from "@/entities/user"; +import "./user-signup-modal.css"; + +interface UserSignupModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +interface UserSignupData { + userId: string; + userName: string; + email?: string; + password: string; + pswdHnt: string; + pswdAns?: string; + mphn?: string; + telno?: string; + zip?: string; + addr?: string; + daddr?: string; + laeSeCd: string; + ogdpSeCd: string; + instSeCd?: string; + dtlInstCd?: string; + enoCd?: string; +} + + + +// 익명 사용자용 CodeSelect 컴포넌트 +interface AnonymousCodeSelectProps { + upCd: string; + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +function AnonymousCodeSelect({ upCd, value, onValueChange, disabled, placeholder = "선택" }: AnonymousCodeSelectProps) { + const [codes, setCodes] = useState>([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchCodes = async () => { + if (!upCd) return; + + setIsLoading(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL + }); + + const response = await signupClient.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}` + ); + + if (response.data && Array.isArray(response.data)) { + setCodes(response.data); + } else { + setCodes([]); + } + } catch (error) { + console.error(`공통 코드 로드 실패 (${upCd}):`, error); + setCodes([]); + } finally { + setIsLoading(false); + } + }; + + fetchCodes(); + }, [upCd]); + + return ( + + ); +} + +function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) { + const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement'); + const [agreed, setAgreed] = useState(false); + const [formData, setFormData] = useState({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingId, setIsCheckingId] = useState(false); + const [isCheckingEmail, setIsCheckingEmail] = useState(false); + const [idAvailable, setIdAvailable] = useState(null); + const [emailAvailable, setEmailAvailable] = useState(null); + + // 모달이 닫힐 때 초기화 + useEffect(() => { + if (!open) { + setCurrentStep('agreement'); + setAgreed(false); + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } + }, [open]); + + const handleAgreementChange = (e: React.ChangeEvent) => { + setAgreed(e.target.checked); + }; + + const handleNextStep = () => { + if (agreed) { + setCurrentStep('signup'); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // 에러 초기화 + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: "" })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + console.log("폼 검증 시작:", formData); + + // 필수 필드 검증 + if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; + if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; + if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; + if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; + if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 선택해주세요."; + if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; + if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; + + // 비밀번호 확인 + if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; + } + + // 비밀번호 정규식 검증 + const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; + if (formData.password && !passwordRegex.test(formData.password)) { + newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; + } + + // 이메일 형식 검증 + if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "올바른 이메일 형식을 입력해주세요."; + } + + // 중복 체크 검증 + if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다."; + if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다."; + + setErrors(newErrors); + const isValid = Object.keys(newErrors).length === 0; + console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); + return isValid; + }; + + const checkUserIdDuplicate = async () => { + if (!formData.userId) { + setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." })); + return; + } + + setIsCheckingId(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + { userId: formData.userId } + ); + + const exists = response.data; + setIdAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." })); + } else { + setErrors(prev => ({ ...prev, userId: "" })); + } + } catch (error) { + console.error("아이디 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingId(false); + } + }; + + const checkEmailDuplicate = async () => { + if (!formData.email) { + setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." })); + return; + } + + setIsCheckingEmail(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + { email: formData.email } + ); + + const exists = response.data; + setEmailAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." })); + } else { + setErrors(prev => ({ ...prev, email: "" })); + } + } catch (error) { + console.error("이메일 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingEmail(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + console.log("회원가입 버튼 클릭됨 - handleSubmit"); + console.log("현재 폼 데이터:", formData); + + if (!validateForm()) { + console.log("폼 검증 실패"); + return; + } + + console.log("폼 검증 성공, 회원가입 API 호출 시작"); + setIsLoading(true); + try { + const signupData: UserSignupData = { + userId: formData.userId, + userName: formData.userName, + email: formData.email || undefined, + password: formData.password, + pswdHnt: formData.pswdHnt, + pswdAns: formData.pswdAns || undefined, + mphn: formData.mphn || undefined, + telno: formData.telno || undefined, + zip: formData.zip || undefined, + addr: formData.addr || undefined, + daddr: formData.daddr || undefined, + laeSeCd: formData.laeSeCd, + ogdpSeCd: formData.ogdpSeCd, + instSeCd: formData.instSeCd || undefined, + dtlInstCd: formData.dtlInstCd || undefined, + enoCd: formData.enoCd || undefined, + }; + + // SignupClient를 통해 익명사용자 회원가입 API 호출 + console.log("SignupClient 생성 및 API 호출:", { + baseURL: BASE_URL, + endpoint: ENDPOINT.USER_SERVICE.USER_JOIN, + data: signupData + }); + + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL + }); + const response = await signupClient.post( + ENDPOINT.USER_SERVICE.USER_JOIN, + signupData + ); + + console.log("회원가입 성공:", response.data); + + // 성공 시 모달 닫기 및 콜백 호출 + onSuccess(); + onClose(); + + // 폼 초기화 + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + + } catch (error: unknown) { + console.error("회원가입 오류:", error); + if (error instanceof Error) { + setErrors(prev => ({ ...prev, submit: error.message })); + } else { + setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." })); + } + } finally { + setIsLoading(false); + } + }; + + if (!open) return null; + + return ( +
+
+
+

{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}

+ +
+ + {currentStep === 'agreement' ? ( +
+
+

개인정보 수집 및 이용 동의

+

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

+ +

1. 수집하는 개인정보 항목

+
    +
  • 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분
  • +
  • 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호
  • +
+ +

2. 개인정보의 수집 및 이용목적

+
    +
  • 회원가입 및 관리
  • +
  • 서비스 제공 및 운영
  • +
  • 고객상담 및 문의응답
  • +
+ +

3. 개인정보의 보유 및 이용기간

+

회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)

+ +

4. 동의 거부권 및 거부 시 불이익

+

개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.

+
+ +
+ +
+ +
+ + +
+
+ ) : ( +
+
+

기본 정보

+ +
+
+ +
+ + +
+ {idAvailable === true && 사용 가능한 아이디입니다.} + {errors.userId && {errors.userId}} +
+
+ +
+
+ + + {errors.userName && {errors.userName}} +
+
+ +
+
+ +
+ + +
+ {emailAvailable === true && 사용 가능한 이메일입니다.} + {errors.email && {errors.email}} +
+
+ +
+
+ + + {errors.password && {errors.password}} +
+
+ +
+
+ + + {errors.confirmPassword && {errors.confirmPassword}} +
+
+ +
+
+ + setFormData(prev => ({ ...prev, pswdHnt: value }))} + disabled={isLoading} + placeholder="비밀번호 힌트를 선택하세요" + /> + {errors.pswdHnt && {errors.pswdHnt}} +
+
+ +
+
+ + + {errors.pswdAns && {errors.pswdAns}} +
+
+
+ +
+

연락처 정보

+ +
+
+ + + {errors.mphn && {errors.mphn}} +
+
+ +
+
+ + + {errors.telno && {errors.telno}} +
+
+
+ +
+

주소 정보

+ +
+
+ + + {errors.zip && {errors.zip}} +
+
+ +
+
+ + + {errors.addr && {errors.addr}} +
+
+ +
+
+ + + {errors.daddr && {errors.daddr}} +
+
+
+ +
+

기관 정보

+ +
+
+ + setFormData(prev => ({ ...prev, laeSeCd: value }))} + disabled={isLoading} + placeholder="법인구분을 선택하세요" + /> + {errors.laeSeCd && {errors.laeSeCd}} +
+
+ +
+
+ + setFormData(prev => ({ ...prev, ogdpSeCd: value }))} + disabled={isLoading} + placeholder="기관구분을 선택하세요" + /> + {errors.ogdpSeCd && {errors.ogdpSeCd}} +
+
+ +
+
+ + + {errors.instSeCd && {errors.instSeCd}} +
+
+ +
+
+ + + {errors.dtlInstCd && {errors.dtlInstCd}} +
+
+
+ + {errors.submit && ( +
+
{errors.submit}
+
+ )} + +
+ + + +
+
+ )} +
+
+ ); +} + +export default UserSignupModal; diff --git a/apps/cm/src/pages/auth/login/ui/login-page.tsx b/apps/cm/src/pages/auth/login/ui/login-page.tsx index 251cd2819..fed7be4ad 100644 --- a/apps/cm/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cm/src/pages/auth/login/ui/login-page.tsx @@ -7,6 +7,7 @@ import { LoginApi } from "@/entities/auth"; import { ENDPOINT } from "@/shared/config"; import OtpModal from "./OtpModal"; import TwoFAModal from "./TwoFAModal"; +import UserSignupModal from "./UserSignupModal"; import "./login.css"; import { useRouter } from "next/navigation"; @@ -14,8 +15,10 @@ function LoginPage() { const [isOtpEnabled, setIsOtpEnabled] = useState(false); const [showOtpModal, setShowOtpModal] = useState(false); const [showTwoFAModal, setShowTwoFAModal] = useState(false); + const [showSignupModal, setShowSignupModal] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); const router = useRouter(); // 모달 상태 디버깅 @@ -27,6 +30,7 @@ function LoginPage() { e.preventDefault(); setIsLoading(true); setError(""); + setSuccessMessage(""); try { const formData = new FormData(e.target as HTMLFormElement); @@ -123,6 +127,18 @@ function LoginPage() { localStorage.removeItem("tempToken"); }; + const handleSignupSuccess = () => { + console.log("회원가입 성공"); + setError(""); // 에러 메시지 초기화 + setSuccessMessage("회원가입이 완료되었습니다. 로그인해주세요."); + }; + + const handleSignupClose = () => { + console.log("회원가입 모달 닫기"); + setShowSignupModal(false); + setSuccessMessage(""); // 성공 메시지 초기화 + }; + return ( <>
@@ -214,6 +230,28 @@ function LoginPage() { {error}
)} + {successMessage && ( +
+ {successMessage} +
+ )} +
+ 계정이 없으신가요? + +

copyright © 2023 KECO. All rights reserved.

@@ -269,6 +307,13 @@ function LoginPage() { open={showTwoFAModal} onOpenChange={setShowTwoFAModal} /> + + {/* 회원가입 모달 */} + ); } diff --git a/apps/cm/src/pages/auth/login/ui/signup-modal.css b/apps/cm/src/pages/auth/login/ui/signup-modal.css new file mode 100644 index 000000000..0fb1585c9 --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/signup-modal.css @@ -0,0 +1,277 @@ +/* 모달 오버레이 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +/* 모달 컨텐츠 */ +.modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +/* 모달 헤더 */ +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; + background-color: #f9fafb; + border-radius: 8px 8px 0 0; +} + +.modal-header h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: #1f2937; +} + +.close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6b7280; + padding: 4px; + border-radius: 4px; + transition: color 0.2s; +} + +.close-button:hover { + color: #374151; +} + +/* 폼 스타일 */ +.signup-form { + padding: 24px; +} + +.form-section { + margin-bottom: 32px; +} + +.form-section h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; + padding-bottom: 8px; + border-bottom: 2px solid #e5e7eb; +} + +.form-row { + margin-bottom: 16px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + margin-bottom: 6px; + font-weight: 500; + color: #374151; + font-size: 1.1rem; +} + +.form-group input, +.form-group select { + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-group input:disabled, +.form-group select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* 입력 필드와 버튼이 함께 있는 경우 */ +.input-with-button { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.input-with-button input { + flex: 1; +} + +.check-button { + padding: 10px 16px; + background-color: #6b7280; + color: white; + border: none; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; + min-width: 80px; +} + +.check-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.check-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 메시지 스타일 */ +.error-message { + color: #dc2626; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.success-message { + color: #059669; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.submit-error { + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; + text-align: center; +} + +/* 폼 액션 버튼 */ +.form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + margin-top: 24px; +} + +.cancel-button, +.submit-button { + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + border: none; + min-width: 100px; +} + +.cancel-button { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.cancel-button:hover:not(:disabled) { + background-color: #e5e7eb; +} + +.submit-button { + background-color: #3b82f6; + color: white; +} + +.submit-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.cancel-button:disabled, +.submit-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .modal-content { + margin: 10px; + max-height: 95vh; + } + + .modal-header { + padding: 16px 20px; + } + + .signup-form { + padding: 20px; + } + + .form-actions { + flex-direction: column; + } + + .cancel-button, + .submit-button { + width: 100%; + } + + .input-with-button { + flex-direction: column; + align-items: stretch; + } + + .check-button { + width: 100%; + margin-top: 8px; + } +} + +/* 스크롤바 스타일 */ +.modal-content::-webkit-scrollbar { + width: 6px; +} + +.modal-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.modal-content::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.modal-content::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} diff --git a/apps/cm/src/pages/auth/login/ui/user-signup-modal.css b/apps/cm/src/pages/auth/login/ui/user-signup-modal.css new file mode 100644 index 000000000..241929a2a --- /dev/null +++ b/apps/cm/src/pages/auth/login/ui/user-signup-modal.css @@ -0,0 +1,436 @@ +/* 신규 사용자 가입 모달 오버레이 */ +.user-signup-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +/* 신규 사용자 가입 모달 컨텐츠 */ +.user-signup-modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +/* 신규 사용자 가입 모달 헤더 */ +.user-signup-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; + background-color: #f9fafb; + border-radius: 8px 8px 0 0; +} + +.user-signup-modal-header h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: #1f2937; +} + +.user-signup-close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6b7280; + padding: 4px; + border-radius: 4px; + transition: color 0.2s; +} + +.user-signup-close-button:hover { + color: #374151; +} + +/* 신규 사용자 가입 폼 스타일 */ +.user-signup-form { + padding: 24px; +} + +.user-signup-form-section { + margin-bottom: 32px; +} + +.user-signup-form-section h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; + padding-bottom: 8px; + border-bottom: 2px solid #e5e7eb; +} + +.user-signup-form-row { + margin-bottom: 16px; +} + +.user-signup-form-group { + display: flex; + flex-direction: column; +} + +.user-signup-form-group label { + margin-bottom: 6px; + font-weight: 500; + color: #374151; + font-size: 1.1rem; +} + +.user-signup-form-group input, +.user-signup-form-group select { + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +/* CodeSelect 컴포넌트 스타일링 */ +.user-signup-form-group .CodeSelect { + width: 100%; +} + +.user-signup-form-group .CodeSelect select { + width: 100%; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +.user-signup-form-group .CodeSelect select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-form-group .CodeSelect select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* Select 요소 스타일링 */ +.user-signup-select { + width: 100%; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; + cursor: pointer; +} + +.user-signup-select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +.user-signup-select option { + padding: 8px; + font-size: 1rem; +} + +.user-signup-form-group input:focus, +.user-signup-form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-form-group input:disabled, +.user-signup-form-group select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* 입력 필드와 버튼이 함께 있는 경우 */ +.user-signup-input-with-button { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.user-signup-input-with-button input { + flex: 1; +} + +.user-signup-check-button { + padding: 10px 16px; + background-color: #6b7280; + color: white; + border: none; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; + min-width: 80px; +} + +.user-signup-check-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.user-signup-check-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 메시지 스타일 */ +.user-signup-error-message { + color: #dc2626; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.user-signup-success-message { + color: #059669; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.user-signup-submit-error { + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; + text-align: center; +} + +/* 폼 액션 버튼 */ +.user-signup-form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + margin-top: 24px; +} + +.user-signup-cancel-button, +.user-signup-submit-button, +.user-signup-back-button { + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + border: none; + min-width: 100px; +} + +.user-signup-cancel-button { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.user-signup-cancel-button:hover:not(:disabled) { + background-color: #e5e7eb; +} + +.user-signup-submit-button { + background-color: #3b82f6; + color: white; +} + +.user-signup-submit-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.user-signup-back-button { + background-color: #6b7280; + color: white; +} + +.user-signup-back-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.user-signup-cancel-button:disabled, +.user-signup-submit-button:disabled, +.user-signup-back-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 개인정보제공동의 스타일 */ +.user-signup-agreement-content { + padding: 24px; +} + +.user-signup-agreement-text { + margin-bottom: 24px; +} + +.user-signup-agreement-text h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; +} + +.user-signup-agreement-text h4 { + margin: 20px 0 8px 0; + font-size: 1.1rem; + font-weight: 600; + color: #374151; +} + +.user-signup-agreement-text p { + margin: 8px 0; + color: #6b7280; + line-height: 1.5; +} + +.user-signup-agreement-text ul { + margin: 8px 0; + padding-left: 20px; + color: #6b7280; +} + +.user-signup-agreement-text li { + margin: 4px 0; + line-height: 1.5; +} + +.user-signup-agreement-checkbox { + margin-bottom: 24px; + padding: 16px; + background-color: #f9fafb; + border-radius: 6px; + border: 1px solid #e5e7eb; +} + +.user-signup-agreement-checkbox label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 500; + color: #374151; +} + +.user-signup-agreement-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; +} + +.user-signup-agreement-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.user-signup-next-button { + padding: 12px 24px; + background-color: #3b82f6; + color: white; + border: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s; + min-width: 100px; +} + +.user-signup-next-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.user-signup-next-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .user-signup-modal-content { + margin: 10px; + max-height: 95vh; + } + + .user-signup-modal-header { + padding: 16px 20px; + } + + .user-signup-form { + padding: 20px; + } + + .user-signup-form-actions { + flex-direction: column; + } + + .user-signup-cancel-button, + .user-signup-submit-button, + .user-signup-back-button { + width: 100%; + } + + .user-signup-input-with-button { + flex-direction: column; + align-items: stretch; + } + + .user-signup-check-button { + width: 100%; + margin-top: 8px; + } +} + +/* 스크롤바 스타일 */ +.user-signup-modal-content::-webkit-scrollbar { + width: 6px; +} + +.user-signup-modal-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.user-signup-modal-content::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.user-signup-modal-content::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx index 3b59d4a23..c4901a00e 100644 --- a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -29,7 +29,7 @@ function DashboardPage() { if (!accessToken || !userId) { console.log('No tokens found, redirecting to login'); setError('로그인이 필요합니다.'); - router.push('/login'); + router.push('/auth/login'); return; } @@ -49,7 +49,7 @@ function DashboardPage() { if (error instanceof Error && error.message.includes('401')) { setError('인증이 만료되었습니다. 다시 로그인해주세요.'); tokenManager.clearAuthToken(); - router.push('/login'); + router.push('/auth/login'); } else { setError('네트워크 오류가 발생했습니다.'); } @@ -65,7 +65,7 @@ function DashboardPage() { localStorage.removeItem('tempToken'); // tokenManager를 통해 쿠키 정리 tokenManager.clearAuthToken(); - router.push('/login'); + router.push('/auth/logout'); }; const handleChangePassword = () => { diff --git a/apps/cm/src/pages/users/api/zip-service.ts b/apps/cm/src/pages/users/api/zip-service.ts new file mode 100644 index 000000000..211638344 --- /dev/null +++ b/apps/cm/src/pages/users/api/zip-service.ts @@ -0,0 +1,41 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface ZipSearchParams extends PageableParams { + zoneNo?: string; // 구역번호 + ctpvNm?: string; // 시도명 + sggNm?: string; // 시군구명 + emdNm?: string; // 읍면동명 + roadNm?: string; // 도로명 + stdgNm?: string; // 법정동명 + dongNm?: string; // 행정동명 + lotnoMno?: number | null; // 지번본번 + lotnoSno?: number | null; // 지번부번 +} + +export interface Zip { + zipId: number; + zoneNo: string; + ctpvNm: string; + sggNm: string; + emdNm: string; + roadNm: string; + lotnoMno: number; + lotnoSno: number; + lotno: string; + zipAddr: string; +} + +type ZipList = PageableList; + +export class ZipApiService { + public async searchZip(params: ZipSearchParams): Promise { + const response = await client.get(`${ENDPOINT.CMS_SERVICE.ZIPS}`, { + params, + }); + return response.data; + } +} + +export const ZipApi = new ZipApiService(); diff --git a/apps/cm/src/pages/users/index.ts b/apps/cm/src/pages/users/index.ts new file mode 100644 index 000000000..096f72195 --- /dev/null +++ b/apps/cm/src/pages/users/index.ts @@ -0,0 +1 @@ +export { CreatePage } from "./ui/create-page"; diff --git a/apps/cm/src/pages/users/model/table-columns.tsx b/apps/cm/src/pages/users/model/table-columns.tsx new file mode 100644 index 000000000..1aad6de27 --- /dev/null +++ b/apps/cm/src/pages/users/model/table-columns.tsx @@ -0,0 +1,88 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { + USER_STATE_CODE, + type UserListResponse, + type UserStateCode, +} from "@/entities/user"; +import { Badge } from "@common/components"; + +export const columns: ColumnDef[] = [ + { + id: "no", + header: "No", + cell: ({ row, table }) => { + const totalRows = table.getRowCount(); + const currentPage = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + return totalRows - (currentPage * pageSize + row.index); + }, + }, + { + accessorKey: "userId", + header: "아이디", + }, + { + accessorKey: "userName", + header: "이름", + }, + { + accessorKey: "telno", + header: "전화번호", + }, + { + accessorKey: "email", + header: "이메일", + }, + { + accessorKey: "laeSeNm", + header: "구분", + }, + { + accessorKey: "ogdpSeNm", + header: "소속", + }, + { + accessorKey: "instSeNm", + header: "기관/부서", + }, + { + accessorKey: "userStateCode", + header: "회원상태", + cell: ({ row }) => { + return getUserStateBadge(row.original.userStateCode); + }, + }, + { + accessorKey: "aprvYmd", + header: "승인일자", + }, + { + accessorKey: "loginFailCount", + header: "로그인실패횟수", + }, + { + accessorKey: "lastLoginDate", + header: "최종접속일", + cell: ({ row }) => { + return row.original.lastLoginDate + ? dayjs(row.original.lastLoginDate).format("YYYY-MM-DD") + : ""; + }, + }, +]; + +const getUserStateBadge = (userStateCode: UserStateCode) => { + switch (userStateCode) { + case USER_STATE_CODE.대기: + return 대기; + case USER_STATE_CODE.정상: + return 정상; + case USER_STATE_CODE.정지: + return 정지; + case USER_STATE_CODE.탈퇴: + return 탈퇴; + case USER_STATE_CODE.삭제: + return 삭제; + } +}; diff --git a/apps/cm/src/pages/users/ui/agency-search-dialog.tsx b/apps/cm/src/pages/users/ui/agency-search-dialog.tsx new file mode 100644 index 000000000..9759d42be --- /dev/null +++ b/apps/cm/src/pages/users/ui/agency-search-dialog.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + getCoreRowModel, + getPaginationRowModel, + type PaginationState, +} from "@tanstack/react-table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, + Button, + Input, + Label, +} from "@common/components"; +import { + LwpoCodeApi, + type LwpoCode, + type LwpoCodeParams, +} from "@/entities/code"; +import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; + +interface AgencySearchDialogProps { + onSelectAgency: (data: { lwpoCd: string; lwpoCdNm: string }) => void; +} + +function AgencySearchDialog({ onSelectAgency }: AgencySearchDialogProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [selectedAgency, setSelectedAgency] = useState(null); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [searchQuery, setSearchQuery] = useState({ + lwpoCdNm: "", + }); + + const query = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, pagination], + queryFn: () => + LwpoCodeApi.getLwpoCodeList({ + ...searchQuery, + upCd: "AGENCYCD", + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const handleRowClick = (row: LwpoCode) => { + table.setRowSelection({ + [row.lwpoCd.toString()]: true, + }); + setSelectedAgency(row); + }; + + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns: [ + { + accessorKey: "lwpoCd", + header: "기관코드", + meta: { + width: 200, + }, + }, + { + accessorKey: "lwpoCdNm", + header: "기관명", + }, + ], + state: { + pagination, + }, + manualPagination: true, + enableMultiRowSelection: false, + getRowId: (row) => row.lwpoCd.toString(), + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + variant: "secondary", + }, + }); + + const handleSearch = async () => { + await query.refetch(); + queryClient.removeQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE], + }); + setPagination({ + pageIndex: 0, + pageSize: 10, + }); + table.setRowSelection({}); + setSelectedAgency(null); + }; + + const handleSelectAgency = () => { + if (!selectedAgency) return; + onSelectAgency({ + lwpoCd: selectedAgency.lwpoCd, + lwpoCdNm: selectedAgency.lwpoCdNm, + }); + setOpen(false); + }; + + return ( + + + + + + + 기관 검색 + +
{ + e.preventDefault(); + e.stopPropagation(); + handleSearch(); + }} + > +
+
+ + + setSearchQuery({ + ...searchQuery, + lwpoCdNm: e.target.value, + }) + } + placeholder="기관명을 입력하세요" + className="!text-[1.3rem]" + /> + +
+
+
+ {tableComponent} + + + + + + +
+
+ ); +} + +export { AgencySearchDialog }; diff --git a/apps/cm/src/pages/users/ui/create-page.tsx b/apps/cm/src/pages/users/ui/create-page.tsx new file mode 100644 index 000000000..2d55a1cd2 --- /dev/null +++ b/apps/cm/src/pages/users/ui/create-page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutationWrapper, useAlertStore } from "@/shared/lib"; +import { UserApi, userSaveSchema, type UserSaveData } from "@/entities/user"; +import { UsersForm } from "./users-form"; + +function CreatePage() { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + + const form = useForm({ + resolver: zodResolver(userSaveSchema), + defaultValues: { + userId: "", + userName: "", + password: "", + pswdHnt: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + }, + }); + + // 사용자 등록 mutation + const { mutateAsync: saveUser } = useMutationWrapper({ + mutationFn: (data: UserSaveData) => UserApi.saveUser(data), + onSuccess: () => { + alert("회원이 등록되었습니다.", router.back); + }, + }); + + return ( +
+
+

회원 등록

+
+ +
+ ); +} + +export { CreatePage }; diff --git a/apps/cm/src/pages/users/ui/modify-page.tsx b/apps/cm/src/pages/users/ui/modify-page.tsx new file mode 100644 index 000000000..fc93e4b05 --- /dev/null +++ b/apps/cm/src/pages/users/ui/modify-page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { + UserApi, + userUpdateSchema, + type UserUpdateData, +} from "@/entities/user"; +import { ENDPOINT } from "@/shared/config"; +import { useMutationWrapper, useAlertStore } from "@/shared/lib"; +import { UsersForm } from "./users-form"; + +interface ModifyPageProps { + userUnqId: string; +} + +export function ModifyPage({ userUnqId }: ModifyPageProps) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + + const { data: userData } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + queryFn: () => UserApi.findByUserUnqId(userUnqId), + enabled: !!userUnqId, + }); + + const form = useForm({ + resolver: zodResolver(userUpdateSchema), + defaultValues: { + userId: userData?.userId ?? "", + userName: userData?.userName ?? "", + email: userData?.email ?? "", + userStateCode: userData?.userStateCode ?? "", + pswdHnt: userData?.pswdHnt ?? "", + pswdAns: userData?.pswdAns ?? "", + mphn: userData?.mphn ?? "", + telno: userData?.telno ?? "", + zip: userData?.zip ?? "", + addr: userData?.addr ?? "", + daddr: userData?.daddr ?? "", + laeSeCd: userData?.laeSeCd ?? "", + ogdpSeCd: userData?.ogdpSeCd ?? "", + aprvYn: userData?.aprvYn ?? "", + expryYmd: userData?.expryYmd ?? "", + instSeCd: userData?.instSeCd ?? "", + dtlInstCd: userData?.dtlInstCd ?? "", + aprvYmd: userData?.aprvYmd ?? "", + enoCd: userData?.enoCd ?? "", + }, + }); + + const { mutateAsync: updateUser } = useMutationWrapper({ + mutationFn: (data: UserUpdateData) => UserApi.updateUser(userUnqId, data), + onSuccess: () => { + alert("회원 정보가 수정되었습니다.", router.back); + }, + }); + + return ( +
+
+

회원 정보 수정

+
+ +
+ ); +} diff --git a/apps/cm/src/pages/users/ui/user-detail.tsx b/apps/cm/src/pages/users/ui/user-detail.tsx new file mode 100644 index 000000000..fbfa016c2 --- /dev/null +++ b/apps/cm/src/pages/users/ui/user-detail.tsx @@ -0,0 +1,187 @@ +"use client"; + +import dayjs from "dayjs"; +import { usePathname, useRouter } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Button, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { USER_STATE_CODE, UserApi, PasswordResetDialog } from "@/entities/user"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; + +function UserDetail({ userUnqId }: { userUnqId: string }) { + const router = useRouter(); + const pathname = usePathname(); + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const alert = useAlertStore((state) => state.setMessage); + + const { data, refetch } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + queryFn: () => UserApi.findByUserUnqId(userUnqId), + enabled: !!userUnqId, + }); + + const { mutate: onLockUser } = useMutationWrapper({ + mutationFn: () => UserApi.lockUser(userUnqId), + onSuccess: () => { + alert("계정 잠금이 활성화 되었습니다."); + refetch(); + }, + }); + + const { mutate: onUnlockUser } = useMutationWrapper({ + mutationFn: () => UserApi.unlockUser(userUnqId), + onSuccess: () => { + alert("계정 잠금이 해제되었습니다."); + refetch(); + }, + }); + + const { mutate: onApplyUser } = useMutationWrapper({ + mutationFn: () => UserApi.applyUser(userUnqId), + onSuccess: () => { + alert("사용자 승인이 완료되었습니다."); + refetch(); + }, + }); + + const { mutateAsync: deleteUser } = useMutationWrapper({ + mutationFn: () => UserApi.deleteUser(userUnqId), + onSuccess: () => { + alert("회원이 삭제되었습니다.", router.back); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.USER_SERVICE.USERS], + }); + }, + }); + + const handleDelete = async () => { + if (await confirm("삭제하시겠습니까?")) { + await deleteUser(); + } + }; + + 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/cm/src/pages/users/ui/users-form.tsx b/apps/cm/src/pages/users/ui/users-form.tsx new file mode 100644 index 000000000..7920ce758 --- /dev/null +++ b/apps/cm/src/pages/users/ui/users-form.tsx @@ -0,0 +1,669 @@ +import { useState, useMemo, useCallback } from "react"; +import { + useWatch, + type Path, + type PathValue, + type UseFormReturn, +} from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; +import { + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeApi, CodeSelect } from "@/entities/code"; +import { + UserApi, + type UserSaveData, + type UserUpdateData, +} from "@/entities/user"; +import { + FormField, + FormItem, + FormControl, + FormMessage, +} from "@/shared/ui/form"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { ZipSearchDialog } from "./zip-search-dialog"; +import { AgencySearchDialog } from "./agency-search-dialog"; + +type UsersFormProps = { + userId?: string; + form: UseFormReturn; + handleSave: (data: T) => Promise; + handleDelete?: () => Promise; +}; + +function UsersForm({ + userId, + form, + ...props +}: UsersFormProps) { + const isModifyMode = !!userId; + + const [watchOgdpSeCd, watchDtlInstCd] = useWatch({ + control: form.control, + name: ["ogdpSeCd" as Path, "dtlInstCd" as Path], + }); + const alert = useAlertStore((state) => state.setMessage); + + const [isValidUserId, setIsValidUserId] = useState(false); + const [isValidEmail, setIsValidEmail] = useState(false); + const [passwordMatch, setPasswordMatch] = useState(true); + const [instSeCdNm, setInstSeCdNm] = useState(""); + + const { data: authSeCd } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"], + queryFn: () => CodeApi.getCode("AUTHSECD"), + }); + + const { data: laeSeCd } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"], + queryFn: () => CodeApi.getCode("LAESECD"), + }); + + const { mutateAsync: checkDuplicateUserId } = useMutationWrapper({ + mutationFn: (userId: string) => UserApi.existsUserId({ userId }), + onSuccess: (duplicate) => { + if (!duplicate) { + alert("사용가능한 아이디입니다."); + setIsValidUserId(true); + return; + } + alert("중복된 아이디입니다."); + }, + }); + + const { mutateAsync: checkDuplicateEmail } = useMutationWrapper({ + mutationFn: (email: string) => UserApi.existsEmail({ email }), + onSuccess: (duplicate) => { + if (!duplicate) { + alert("사용가능한 이메일입니다."); + setIsValidEmail(true); + return; + } + alert("중복된 이메일입니다."); + }, + }); + + const authSeCdSelectData = useMemo(() => { + if (watchOgdpSeCd === "1") + 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, watchOgdpSeCd]); + + const laeSeCdSelectData = useMemo(() => { + if (watchDtlInstCd === "ROLE_USER1") + return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? []; + if (watchDtlInstCd === "ROLE_USER2") + return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? []; + if (watchDtlInstCd === "ROLE_USER3") + return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? []; + if (watchDtlInstCd === "ROLE_USER4") + 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, watchDtlInstCd]); + + const enoCdSelectData = useMemo( + () => laeSeCd?.filter((code) => code.lwpoCd?.startsWith("G2")) ?? [], + [laeSeCd], + ); + + const handlePasswordConfirmChange = useCallback((value: string) => { + const password = form.getValues("password" as Path); + setPasswordMatch(!password || value === password); + }, []); + + const handleAddressSelect = useCallback( + (data: { zip: string; addr: string }) => { + form.setValue("zip" as Path, data.zip as PathValue>); + form.setValue("addr" as Path, data.addr as PathValue>); + }, + [form], + ); + + const handleAgencySelect = useCallback( + (data: { lwpoCd: string; lwpoCdNm: string }) => { + form.setValue( + "instSeCd" as Path, + data.lwpoCd as PathValue>, + ); + setInstSeCdNm(data.lwpoCdNm); + }, + [form], + ); + + return ( + +
+ + + + + + + {/* 아이디 */} + + 아이디 + + } + render={({ field }) => ( +
+ + + { + field.onChange(e); + setIsValidUserId(false); + }} + className="w-[24rem] !text-[1.3rem]" + placeholder="아이디를 입력하세요" + disabled={isModifyMode} + /> + + + {!isModifyMode && ( + + )} +
+ )} + /> +
+
+ + {/* 비밀번호 */} + + 비밀번호 + + } + render={({ field }) => ( + + + + + + + )} + /> + + + + {/* 비밀번호 확인 */} + + 비밀번호 확인 + +
+ + handlePasswordConfirmChange(e.target.value) + } + className="w-[24rem] !text-[1.3rem]" + placeholder="비밀번호를 다시 입력하세요" + /> + {!passwordMatch && ( +

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

+ )} +
+
+
+ + {/* 비밀번호 힌트 */} + + 비밀번호 힌트 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 비밀번호 정답 */} + + 비밀번호 정답 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 이름 */} + + 이름 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 연락처 */} + + 연락처 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 이메일 */} + + 이메일 + + } + render={({ field }) => ( +
+ + + { + field.onChange(e); + setIsValidEmail(false); + }} + className="w-[24rem] !text-[1.3rem]" + placeholder="이메일을 입력하세요" + /> + + + +
+ )} + /> +
+
+ + {/* 주소 */} + + 주소 + +
+
+ } + render={({ field }) => ( + + + + + + )} + /> + +
+ } + render={({ field }) => ( + + + + + + )} + /> +
+
+
+ + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 기관구분 */} + + 기관구분 + + } + render={({ field }) => ( + + + { + field.onChange(value); + form.setValue( + "dtlInstCd" as Path, + "" as PathValue>, + ); + form.setValue( + "laeSeCd" as Path, + "" as PathValue>, + ); + form.setValue( + "enoCd" as Path, + "" as PathValue>, + ); + }} + className="w-[16rem]" + placeholder="기관 선택" + /> + + + + )} + /> + + + + {/* 세부기관 */} + + 세부기관 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 소속 */} + + 소속 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 환경청 */} + + 환경청 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 환경청 기관/업체명 */} + + 기관/업체명 + +
+ + +
+
+
+
+
+
+
+ ); +} + +export { UsersForm }; diff --git a/apps/cm/src/pages/users/ui/users-page.tsx b/apps/cm/src/pages/users/ui/users-page.tsx new file mode 100644 index 000000000..5362b8b42 --- /dev/null +++ b/apps/cm/src/pages/users/ui/users-page.tsx @@ -0,0 +1,396 @@ +"use client"; + +import dayjs from "dayjs"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; +import { + Button, + Checkbox, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { CodeApi } from "@/entities/code"; +import { + UserApi, + type UserListResponse, + type UserSearchParams, +} from "@/entities/user"; +import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; +import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker"; +import { columns } from "../model/table-columns"; + +type DateRange = { + from?: Date | undefined; + to?: Date | undefined; +}; + +function UsersPage() { + const router = useRouter(); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [presetDateRange, setPresetDateRange] = useState(""); + const [dateRange, setDateRange] = useState({ + from: undefined, + to: undefined, + }); + const [keywordType, setKeywordType] = useState("all"); + const [keyword, setKeyword] = useState(""); + const [userStateCode, setUserStateCode] = useState(""); + const [aprvYn, setAprvYn] = useState(""); + const [grpId, setGrpId] = useState(""); + + const [searchParams, setSearchParams] = useState({ + keywordType: "all", + keyword: "", + userStateCode: "", + aprvYn: "", + grpId: "", + frDateYmd: "", + toDateYmd: "", + sort: "regDt,desc", + }); + + const { data: userSttsStats } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, "userStatusStatistics"], + queryFn: () => UserApi.selectUserSttsStats(), + }); + + const query = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, searchParams, pagination], + queryFn: () => + UserApi.findPageUser({ + ...searchParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + }); + + const { data: userStateCodes } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE], + queryFn: () => CodeApi.getCode("USERSTTS"), + }); + + const handleRowClick = (row: UserListResponse) => { + router.push(`/users/${row.userUnqId}`); + }; + + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + }, + manualPagination: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setPagination({ + ...pagination, + pageIndex: 0, + }); + setSearchParams({ + ...searchParams, + keywordType, + keyword, + userStateCode, + aprvYn, + grpId, + frDateYmd: dateRange?.from + ? dayjs(dateRange.from).format("YYYYMMDD") + : "", + toDateYmd: dateRange?.to ? dayjs(dateRange.to).format("YYYYMMDD") : "", + }); + }; + + const handleReset = () => { + setKeywordType("all"); + setKeyword(""); + setUserStateCode(""); + setAprvYn(""); + setGrpId(""); + setDateRange({ + from: undefined, + to: undefined, + }); + setSearchParams({ + ...searchParams, + keywordType: "all", + keyword: "", + userStateCode: "", + aprvYn: "", + grpId: "", + frDateYmd: "", + toDateYmd: "", + }); + }; + + const handlePresetDateRange = (value: string) => { + let presetDateRange: DateRange; + switch (value) { + case "1M": + presetDateRange = { + from: dayjs().subtract(1, "month").toDate(), + to: dayjs().toDate(), + }; + break; + case "3M": + presetDateRange = { + from: dayjs().subtract(3, "month").toDate(), + to: dayjs().toDate(), + }; + break; + case "6M": + presetDateRange = { + from: dayjs().subtract(6, "month").toDate(), + to: dayjs().toDate(), + }; + break; + default: + presetDateRange = { + from: undefined, + to: undefined, + }; + } + setPresetDateRange(value); + setDateRange(presetDateRange); + }; + + const handleDateRangeChange = (dateRange: DateRange) => { + setDateRange(dateRange); + setPresetDateRange(""); + }; + + const getUserSttsStatsCnt = (code: string): string => { + if (!Array.isArray(userSttsStats)) return "0"; + const count = userSttsStats.find((item) => item.sttsCd === code)?.cnt ?? 0; + return count.toLocaleString(); + }; + + const handleUserStateCodeChange = (code: string, checked: boolean) => { + let newUserStateCode = userStateCode; + + if (checked) { + newUserStateCode = userStateCode ? `${userStateCode},${code}` : code; + } else { + newUserStateCode = userStateCode + .split(",") + .filter((c) => c !== code) + .join(","); + } + + setUserStateCode(newUserStateCode); + }; + + return ( +
+
+
+
+ +
+ + 승인대기{" "} + + {getUserSttsStatsCnt("00")} + + 명 + +
+
+
+ +
+ + 전체{" "} + + {getUserSttsStatsCnt("TOTAL")} + + 명 + +
+
+
+ +
+ + 잠금{" "} + + {getUserSttsStatsCnt("07")} + + 명 + +
+
+
+ +
+ + 탈퇴{" "} + + {getUserSttsStatsCnt("08")} + + 명 + +
+
+
+
+
+
+ + + setKeyword(e.target.value)} + className="!text-[1.3rem]" + placeholder="검색어를 입력하세요" + /> +
+
+ + {/* Select */} +
+
+ + + +
+
+
+ +
+ {userStateCodes?.map((item) => ( +
+ { + handleUserStateCodeChange(item.lwpoCd, !!checked); + }} + /> + +
+ ))} +
+
+
+
+ + +
+ +
+
+
+
+

회원 목록

+
+ + + +
+
+
+
{tableComponent}
+
+
+ ); +} + +export { UsersPage }; diff --git a/apps/cm/src/pages/users/ui/zip-search-dialog.tsx b/apps/cm/src/pages/users/ui/zip-search-dialog.tsx new file mode 100644 index 000000000..baab27619 --- /dev/null +++ b/apps/cm/src/pages/users/ui/zip-search-dialog.tsx @@ -0,0 +1,224 @@ +import { useState, useRef, useEffect } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + getCoreRowModel, + getPaginationRowModel, + type PaginationState, +} from "@tanstack/react-table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, + Button, + Input, + Label, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; +import { ZipApi, type Zip, type ZipSearchParams } from "../api/zip-service"; + +interface ZipSearchDialogProps { + onSelectAddress: (data: { zip: string; addr: string }) => void; +} + +function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [selectedAddress, setSelectedAddress] = useState(null); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [searchQuery, setSearchQuery] = useState({ + roadNm: "", + lotnoMno: null, + lotnoSno: null, + }); + const hasFetchedOnce = useRef(false); + + const query = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination], + queryFn: () => + ZipApi.searchZip({ + ...searchQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + enabled: false, + }); + + const handleRowClick = (row: Zip) => { + table.setRowSelection({ + [row.zipId.toString()]: true, + }); + setSelectedAddress(row); + }; + + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns: [ + { + accessorKey: "zoneNo", + header: "우편번호", + }, + { + accessorKey: "zipAddr", + header: "주소", + }, + ], + state: { + pagination, + }, + manualPagination: true, + enableMultiRowSelection: false, + getRowId: (row) => row.zipId.toString(), + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }, + dataTableOptions: { + onRowClick: handleRowClick, + viewPagination: true, + variant: "secondary", + }, + }); + + const handleSearch = async () => { + await query.refetch(); + queryClient.removeQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS], + }); + setPagination({ + pageIndex: 0, + pageSize: 10, + }); + table.setRowSelection({}); + setSelectedAddress(null); + hasFetchedOnce.current = true; + }; + + const handleSelectAddress = () => { + if (!selectedAddress) return; + onSelectAddress({ + zip: selectedAddress.zoneNo, + addr: selectedAddress.zipAddr, + }); + setOpen(false); + }; + + useEffect(() => { + if (hasFetchedOnce.current) query.refetch(); + }, [pagination]); + + return ( + + + + + + + 주소 검색 + + +
{ + e.preventDefault(); + e.stopPropagation(); + handleSearch(); + }} + className="mb-4 space-y-2" + > +
+
+
+ + + setSearchQuery({ + ...searchQuery, + roadNm: e.target.value, + }) + } + placeholder="예: 테헤란로" + className="!text-[1.3rem]" + /> +
+ +
+ + { + const value = e.target.value + ? parseInt(e.target.value) + : null; + setSearchQuery({ + ...searchQuery, + lotnoMno: value, + }); + }} + placeholder="예: 123" + className="!text-[1.3rem]" + /> +
+ +
+ + { + const value = e.target.value + ? parseInt(e.target.value) + : null; + setSearchQuery({ + ...searchQuery, + lotnoSno: value, + }); + }} + placeholder="예: 45" + className="!text-[1.3rem]" + /> +
+
+ +
+ +
+
+
+ + {query.data?.content.length && tableComponent} + + + + + + + +
+
+ ); +} + +export { ZipSearchDialog }; diff --git a/apps/cm/src/shared/api/signup-client.ts b/apps/cm/src/shared/api/signup-client.ts new file mode 100644 index 000000000..2cd459cd7 --- /dev/null +++ b/apps/cm/src/shared/api/signup-client.ts @@ -0,0 +1,71 @@ +import axios from "axios"; +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; + +interface SignupClientConfig { + baseURL: string; +} + +class SignupClient { + private static instance: SignupClient; + private axiosInstance: AxiosInstance; + + private constructor(config: SignupClientConfig) { + this.axiosInstance = axios.create({ + baseURL: config.baseURL, + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + }); + + // 인증 토큰 없이도 요청할 수 있도록 인터셉터 설정 + this.axiosInstance.interceptors.request.use((config) => { + // 인증 토큰을 자동으로 추가하지 않음 + return config; + }); + } + + public static getInstance(config: SignupClientConfig): SignupClient { + if (!SignupClient.instance) { + SignupClient.instance = new SignupClient(config); + } + return SignupClient.instance; + } + + public async get>( + url: string, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.get(url, config); + return response; + } + + public async post, D = unknown>( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.post(url, data, config); + return response; + } + + public async put, D = unknown>( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.put(url, data, config); + return response; + } + + public async delete>( + url: string, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.delete(url, config); + return response; + } +} + +export type { SignupClientConfig }; +export { SignupClient }; diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts index d6065bfc7..36dfc69a3 100644 --- a/apps/cm/src/shared/config/api.ts +++ b/apps/cm/src/shared/config/api.ts @@ -25,6 +25,7 @@ export const ENDPOINT = { OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify", TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup", TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable", + USER_JOIN: "/user-service/api/v1/anonymouse/users/join", }, CMS_SERVICE: { DASHBOARD: "/cms-service/api/v1/dashboard",