CM 회원가입 추가
This commit is contained in:
+1
-1
@@ -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
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeSelect } from "../ui/code-select";
|
||||
@@ -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<ICode[]> {
|
||||
const response = await client.get<ICode[]>(
|
||||
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const CodeApi = new CodeApiService();
|
||||
@@ -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<LwpoCodeList> {
|
||||
const response = await client.get<LwpoCodeList>(
|
||||
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList> {
|
||||
const response = await client.get<LwpoCodeList>(
|
||||
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/up-cd/${upCd}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async lwpoCodeDetail(
|
||||
upCd: string,
|
||||
lwpoCd: string,
|
||||
): Promise<LwpoCodeDetail> {
|
||||
const response = await client.get<LwpoCodeDetail>(
|
||||
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async createLwpoCode(data: LwpoCodeFormData): Promise<void> {
|
||||
const validatedData = LwpoCodeFormSchema.parse(data);
|
||||
const response = await client.post<void>(
|
||||
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async updateLwpoCode(
|
||||
upCd: string,
|
||||
lwpoCd: string,
|
||||
data: LwpoCodeFormData,
|
||||
): Promise<void> {
|
||||
const validatedData = LwpoCodeFormSchema.parse(data);
|
||||
const response = await client.put<void>(
|
||||
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async deleteLwpoCode(upCd: string, lwpoCd: string): Promise<void> {
|
||||
await client.delete<void>(
|
||||
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
|
||||
);
|
||||
}
|
||||
|
||||
public async codeRefreshAll(): Promise<void> {
|
||||
await client.post<void>(
|
||||
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/cache/refresh-all`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const LwpoCodeApi = new LwpoCodeApiService();
|
||||
@@ -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<UpCodeList> {
|
||||
const response = await client.get<UpCodeList>(
|
||||
`${ENDPOINT.CMS_SERVICE.UP_CODE}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async upCodeDetail(upCd: string): Promise<UpCodeDetail> {
|
||||
const response = await client.get<UpCodeDetail>(
|
||||
`${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async createUpCode(data: UpCodeFormData): Promise<void> {
|
||||
const validatedData = upCodeFormSchema.parse(data);
|
||||
const response = await client.post<void>(
|
||||
`${ENDPOINT.CMS_SERVICE.UP_CODE}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async updateUpCode(upCd: string, data: UpCodeFormData): Promise<void> {
|
||||
const validatedData = upCodeFormSchema.parse(data);
|
||||
const response = await client.put<void>(
|
||||
`${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`,
|
||||
validatedData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const UpCodeApi = new UpCodeApiService();
|
||||
@@ -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";
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface ICode {
|
||||
lwpoCd: string;
|
||||
upCd: string;
|
||||
lwpoCdNm: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface CodeService {
|
||||
getCode(upCd: string): Promise<ICode[]>;
|
||||
}
|
||||
@@ -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<typeof LwpoCodeFormSchema>;
|
||||
export type LwpoCodeList = PageableList<LwpoCode>;
|
||||
|
||||
export interface LwpoCodeService {
|
||||
getLwpoCodeList(params: LwpoCodeParams): Promise<LwpoCodeList>;
|
||||
getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList>;
|
||||
lwpoCodeDetail(upCd: string, lwpoCd: string): Promise<LwpoCodeDetail>;
|
||||
updateLwpoCode(
|
||||
upCd: string,
|
||||
lwpoCd: string,
|
||||
data: LwpoCodeFormData,
|
||||
): Promise<void>;
|
||||
createLwpoCode(data: LwpoCodeFormData): Promise<void>;
|
||||
deleteLwpoCode(
|
||||
upCd: string,
|
||||
lwpoCd: string,
|
||||
data: LwpoCodeFormData,
|
||||
): Promise<void>;
|
||||
codeRefreshAll(): Promise<void>;
|
||||
}
|
||||
@@ -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<typeof upCodeFormSchema>;
|
||||
export type UpCodeList = PageableList<UpCode>;
|
||||
|
||||
export interface UpCodeService {
|
||||
getUpCodeList(params: UpCodeParams): Promise<UpCodeList>;
|
||||
upCodeDetail(upCodeId: string): Promise<UpCodeDetail>;
|
||||
updateUpCode(upCodeId: string, data: UpCodeFormData): Promise<void>;
|
||||
createUpCode(data: UpCodeFormData): Promise<void>;
|
||||
}
|
||||
@@ -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<typeof Select>;
|
||||
|
||||
function CodeSelect({
|
||||
upCd,
|
||||
placeholder = "선택",
|
||||
className,
|
||||
...props
|
||||
}: CodeSelectProps) {
|
||||
const { data: code } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd],
|
||||
queryFn: () => CodeApi.getCode(upCd),
|
||||
enabled: !!upCd,
|
||||
});
|
||||
|
||||
return (
|
||||
<Select {...props}>
|
||||
<SelectTrigger className={cn("w-[12rem]", className)}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{code?.map((item) => (
|
||||
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
|
||||
{item.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeSelect };
|
||||
@@ -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<UserList> {
|
||||
const response = await client.get<UserList>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 단건 조회
|
||||
public async findByUserUnqId(userUnqId: string): Promise<UserDetailResponse> {
|
||||
const response = await client.get<UserDetailResponse>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 로그인한 사용자 정보 조회
|
||||
public async findByUserUnqIdByLogin(): Promise<UserDetailResponse> {
|
||||
const response = await client.get<UserDetailResponse>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 회원상태 통계 조회
|
||||
public async selectUserSttsStats(): Promise<UserSttsStatsList> {
|
||||
const response = await client.get<UserSttsStatsList>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/stts/stats`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 저장
|
||||
public async saveUser(data: UserSaveData): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 수정
|
||||
public async updateUser(
|
||||
userUnqId: string,
|
||||
data: UserUpdateData,
|
||||
): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 삭제
|
||||
public async deleteUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.delete<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 계정잠금 처리
|
||||
public async lockUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 계정잠금 해제처리
|
||||
public async unlockUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 리프레시 토큰 정보 재발급
|
||||
public async refreshToken(): Promise<void> {
|
||||
await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`);
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 변경
|
||||
public async updatePassword(data: UserPasswordUpdateData): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/update`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 초기화(관리자용)
|
||||
public async initializePassword(
|
||||
userUnqId: string,
|
||||
data: UserPasswordUpdateData,
|
||||
): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 찾기 변경
|
||||
public async changePassword(
|
||||
data: UserFindPasswordUpdateData,
|
||||
): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/change`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 확인
|
||||
public async matchPassword(data: UserPasswordMatchData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/match`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 찾기
|
||||
public async findPasswordUser(
|
||||
data: UserFindPasswordSaveData,
|
||||
): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/find`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 비밀번호 찾기 유효성 확인
|
||||
public async validPassword(token: string): Promise<boolean> {
|
||||
const response = await client.get<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 회원정보 변경
|
||||
public async updateInfo(
|
||||
userUnqId: string,
|
||||
data: UserUpdateInfoData,
|
||||
): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 승인처리
|
||||
public async applyUser(userUnqId: string): Promise<boolean> {
|
||||
const response = await client.put<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 회원탈퇴
|
||||
public async leaveUser(data: UserVerifyData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/leave`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 이메일 중복 체크
|
||||
public async existsEmail(data: UserEmailData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 사용자 아이디 중복 체크
|
||||
public async existsUserId(data: UserIdData): Promise<boolean> {
|
||||
const response = await client.post<boolean>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 관리자 목록 조회
|
||||
public async findPageAdminUser(
|
||||
params: UserSearchParams,
|
||||
): Promise<AdminUserList> {
|
||||
const response = await client.get<AdminUserList>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/admin/list`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const UserApi = new UserApiService();
|
||||
@@ -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";
|
||||
@@ -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<typeof userUpdateSchema>;
|
||||
export type UserPasswordUpdateData = z.infer<typeof userPasswordUpdateSchema>;
|
||||
export type UserFindPasswordUpdateData = z.infer<
|
||||
typeof userFindPasswordUpdateSchema
|
||||
>;
|
||||
export type UserPasswordMatchData = z.infer<typeof userPasswordMatchSchema>;
|
||||
export type UserUpdateInfoData = z.infer<typeof userUpdateInfoSchema>;
|
||||
export type UserSaveData = z.infer<typeof userSaveSchema>;
|
||||
export type UserFindPasswordSaveData = z.infer<
|
||||
typeof userFindPasswordSaveSchema
|
||||
>;
|
||||
export type UserVerifyData = z.infer<typeof userVerifySchema>;
|
||||
export type UserEmailData = z.infer<typeof userEmailSchema>;
|
||||
export type UserIdData = z.infer<typeof userIdSchema>;
|
||||
|
||||
// 페이지네이션 타입 정의
|
||||
export type UserList = PageableList<UserListResponse>;
|
||||
export type UserSttsStatsList = UserSttsStatsResponse[];
|
||||
export type AdminUserList = PageableList<AdminUserListResponse>;
|
||||
|
||||
// 사용자 서비스 인터페이스
|
||||
export interface UserService {
|
||||
// 사용자 목록 조회
|
||||
findPageUser: (params: UserSearchParams) => Promise<UserList>;
|
||||
|
||||
// 사용자 단건 조회
|
||||
findByUserUnqId: (userUnqId: string) => Promise<UserDetailResponse>;
|
||||
|
||||
// 로그인한 사용자 정보 조회
|
||||
findByUserUnqIdByLogin: () => Promise<UserDetailResponse>;
|
||||
|
||||
// 사용자 회원상태 통계 조회
|
||||
selectUserSttsStats: () => Promise<UserSttsStatsList>;
|
||||
|
||||
// 사용자 저장
|
||||
saveUser: (data: UserSaveData) => Promise<string>;
|
||||
|
||||
// 사용자 수정
|
||||
updateUser: (userUnqId: string, data: UserUpdateData) => Promise<string>;
|
||||
|
||||
// 사용자 삭제
|
||||
deleteUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 계정잠금 처리
|
||||
lockUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 계정잠금 해제처리
|
||||
unlockUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 리프레시 토큰 정보 재발급
|
||||
refreshToken: () => Promise<void>;
|
||||
|
||||
// 사용자 비밀번호 변경
|
||||
updatePassword: (data: UserPasswordUpdateData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 초기화(관리자용)
|
||||
initializePassword: (
|
||||
userUnqId: string,
|
||||
data: UserPasswordUpdateData,
|
||||
) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 찾기 변경
|
||||
changePassword: (data: UserFindPasswordUpdateData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 확인
|
||||
matchPassword: (data: UserPasswordMatchData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 찾기
|
||||
findPasswordUser: (data: UserFindPasswordSaveData) => Promise<boolean>;
|
||||
|
||||
// 사용자 비밀번호 찾기 유효성 확인
|
||||
validPassword: (token: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 회원정보 변경
|
||||
updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise<string>;
|
||||
|
||||
// 사용자 승인처리
|
||||
applyUser: (userUnqId: string) => Promise<boolean>;
|
||||
|
||||
// 사용자 회원탈퇴
|
||||
leaveUser: (data: UserVerifyData) => Promise<boolean>;
|
||||
|
||||
// 사용자 이메일 중복 체크
|
||||
existsEmail: (data: UserEmailData) => Promise<boolean>;
|
||||
|
||||
// 사용자 아이디 중복 체크
|
||||
existsUserId: (data: UserIdData) => Promise<boolean>;
|
||||
|
||||
// 관리자 목록 조회
|
||||
findPageAdminUser: (params: UserSearchParams) => Promise<AdminUserList>;
|
||||
}
|
||||
@@ -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<UserPasswordUpdateData>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>비밀번호 초기화</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => onPasswordReset(data))}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>새 비밀번호</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPasswordConfirm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>새 비밀번호 확인</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" className="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">확인</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCheckingId, setIsCheckingId] = useState(false);
|
||||
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
|
||||
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
|
||||
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(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<HTMLInputElement>) => {
|
||||
setAgreed(e.target.checked);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (agreed) {
|
||||
setCurrentStep('signup');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// 에러 초기화
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
// 필수 필드 검증
|
||||
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<boolean>(
|
||||
`${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<boolean>(
|
||||
`${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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}</h2>
|
||||
<button className="close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{currentStep === 'agreement' ? (
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<h3>개인정보 수집 및 이용 동의</h3>
|
||||
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
|
||||
|
||||
<h4>1. 수집하는 개인정보 항목</h4>
|
||||
<ul>
|
||||
<li>필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분</li>
|
||||
<li>선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호</li>
|
||||
</ul>
|
||||
|
||||
<h4>2. 개인정보의 수집 및 이용목적</h4>
|
||||
<ul>
|
||||
<li>회원가입 및 관리</li>
|
||||
<li>서비스 제공 및 운영</li>
|
||||
<li>고객상담 및 문의응답</li>
|
||||
</ul>
|
||||
|
||||
<h4>3. 개인정보의 보유 및 이용기간</h4>
|
||||
<p>회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)</p>
|
||||
|
||||
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
|
||||
<p>개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="agreement-checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={handleAgreementChange}
|
||||
/>
|
||||
<span>개인정보 수집 및 이용에 동의합니다.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="agreement-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cancel-button"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextStep}
|
||||
className="next-button"
|
||||
disabled={!agreed}
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="signup-form">
|
||||
<div className="form-section">
|
||||
<h3>기본 정보</h3>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="userId">아이디 *</label>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
id="userId"
|
||||
name="userId"
|
||||
value={formData.userId}
|
||||
onChange={handleInputChange}
|
||||
placeholder="아이디를 입력하세요"
|
||||
maxLength={12}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkUserIdDuplicate}
|
||||
disabled={isCheckingId || isLoading || !formData.userId}
|
||||
className="check-button"
|
||||
>
|
||||
{isCheckingId ? "확인중..." : "중복확인"}
|
||||
</button>
|
||||
</div>
|
||||
{idAvailable === true && <span className="success-message">사용 가능한 아이디입니다.</span>}
|
||||
{errors.userId && <span className="error-message">{errors.userId}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="userName">이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="userName"
|
||||
name="userName"
|
||||
value={formData.userName}
|
||||
onChange={handleInputChange}
|
||||
placeholder="이름을 입력하세요"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.userName && <span className="error-message">{errors.userName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">이메일</label>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="이메일을 입력하세요"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkEmailDuplicate}
|
||||
disabled={isCheckingEmail || isLoading || !formData.email}
|
||||
className="check-button"
|
||||
>
|
||||
{isCheckingEmail ? "확인중..." : "중복확인"}
|
||||
</button>
|
||||
</div>
|
||||
{emailAvailable === true && <span className="success-message">사용 가능한 이메일입니다.</span>}
|
||||
{errors.email && <span className="error-message">{errors.email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">비밀번호 *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="8~20자, 영문/숫자/특수기호 포함"
|
||||
maxLength={20}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.password && <span className="error-message">{errors.password}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="비밀번호를 다시 입력하세요"
|
||||
maxLength={20}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.confirmPassword && <span className="error-message">{errors.confirmPassword}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pswdHnt"
|
||||
name="pswdHnt"
|
||||
value={formData.pswdHnt}
|
||||
onChange={handleInputChange}
|
||||
placeholder="비밀번호 힌트를 입력하세요"
|
||||
maxLength={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.pswdHnt && <span className="error-message">{errors.pswdHnt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="pswdAns">비밀번호 답변</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pswdAns"
|
||||
name="pswdAns"
|
||||
value={formData.pswdAns}
|
||||
onChange={handleInputChange}
|
||||
placeholder="비밀번호 답변을 입력하세요"
|
||||
maxLength={100}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.pswdAns && <span className="error-message">{errors.pswdAns}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>연락처 정보</h3>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="mphn">휴대폰 번호</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="mphn"
|
||||
name="mphn"
|
||||
value={formData.mphn}
|
||||
onChange={handleInputChange}
|
||||
placeholder="휴대폰 번호를 입력하세요"
|
||||
maxLength={11}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.mphn && <span className="error-message">{errors.mphn}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="telno">전화번호</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="telno"
|
||||
name="telno"
|
||||
value={formData.telno}
|
||||
onChange={handleInputChange}
|
||||
placeholder="전화번호를 입력하세요"
|
||||
maxLength={11}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.telno && <span className="error-message">{errors.telno}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>주소 정보</h3>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="zip">우편번호</label>
|
||||
<input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
value={formData.zip}
|
||||
onChange={handleInputChange}
|
||||
placeholder="우편번호를 입력하세요"
|
||||
maxLength={5}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.zip && <span className="error-message">{errors.zip}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="addr">주소</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr"
|
||||
name="addr"
|
||||
value={formData.addr}
|
||||
onChange={handleInputChange}
|
||||
placeholder="주소를 입력하세요"
|
||||
maxLength={200}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.addr && <span className="error-message">{errors.addr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="daddr">상세주소</label>
|
||||
<input
|
||||
type="text"
|
||||
id="daddr"
|
||||
name="daddr"
|
||||
value={formData.daddr}
|
||||
onChange={handleInputChange}
|
||||
placeholder="상세주소를 입력하세요"
|
||||
maxLength={200}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.daddr && <span className="error-message">{errors.daddr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>기관 정보</h3>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="laeSeCd">법인구분 *</label>
|
||||
<select
|
||||
id="laeSeCd"
|
||||
name="laeSeCd"
|
||||
value={formData.laeSeCd}
|
||||
onChange={handleInputChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">법인구분을 선택하세요</option>
|
||||
<option value="01">공공기관</option>
|
||||
<option value="02">민간기업</option>
|
||||
<option value="03">개인</option>
|
||||
</select>
|
||||
{errors.laeSeCd && <span className="error-message">{errors.laeSeCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="ogdpSeCd">기관구분 *</label>
|
||||
<select
|
||||
id="ogdpSeCd"
|
||||
name="ogdpSeCd"
|
||||
value={formData.ogdpSeCd}
|
||||
onChange={handleInputChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">기관구분을 선택하세요</option>
|
||||
<option value="01">환경부</option>
|
||||
<option value="02">지방자치단체</option>
|
||||
<option value="03">공공기관</option>
|
||||
<option value="04">민간기업</option>
|
||||
</select>
|
||||
{errors.ogdpSeCd && <span className="error-message">{errors.ogdpSeCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="instSeCd">기관코드</label>
|
||||
<input
|
||||
type="text"
|
||||
id="instSeCd"
|
||||
name="instSeCd"
|
||||
value={formData.instSeCd}
|
||||
onChange={handleInputChange}
|
||||
placeholder="기관코드를 입력하세요"
|
||||
maxLength={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.instSeCd && <span className="error-message">{errors.instSeCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="dtlInstCd">상세기관코드</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dtlInstCd"
|
||||
name="dtlInstCd"
|
||||
value={formData.dtlInstCd}
|
||||
onChange={handleInputChange}
|
||||
placeholder="상세기관코드를 입력하세요"
|
||||
maxLength={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.dtlInstCd && <span className="error-message">{errors.dtlInstCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="form-row">
|
||||
<div className="error-message submit-error">{errors.submit}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep('agreement')}
|
||||
className="back-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cancel-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="submit-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "가입 중..." : "회원가입"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignupModal;
|
||||
@@ -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<Array<{ lwpoCd: string; lwpoCdNm: string }>>([]);
|
||||
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 (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
disabled={disabled || isLoading}
|
||||
className="user-signup-select"
|
||||
>
|
||||
<option value="">{isLoading ? "로딩 중..." : placeholder}</option>
|
||||
{codes.map((code) => (
|
||||
<option key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
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<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCheckingId, setIsCheckingId] = useState(false);
|
||||
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
|
||||
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
|
||||
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(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<HTMLInputElement>) => {
|
||||
setAgreed(e.target.checked);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (agreed) {
|
||||
setCurrentStep('signup');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// 에러 초기화
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
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<boolean>(
|
||||
`${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<boolean>(
|
||||
`${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 (
|
||||
<div className="user-signup-modal-overlay">
|
||||
<div className="user-signup-modal-content">
|
||||
<div className="user-signup-modal-header">
|
||||
<h2>{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}</h2>
|
||||
<button className="user-signup-close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{currentStep === 'agreement' ? (
|
||||
<div className="user-signup-agreement-content">
|
||||
<div className="user-signup-agreement-text">
|
||||
<h3>개인정보 수집 및 이용 동의</h3>
|
||||
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
|
||||
|
||||
<h4>1. 수집하는 개인정보 항목</h4>
|
||||
<ul>
|
||||
<li>필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분</li>
|
||||
<li>선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호</li>
|
||||
</ul>
|
||||
|
||||
<h4>2. 개인정보의 수집 및 이용목적</h4>
|
||||
<ul>
|
||||
<li>회원가입 및 관리</li>
|
||||
<li>서비스 제공 및 운영</li>
|
||||
<li>고객상담 및 문의응답</li>
|
||||
</ul>
|
||||
|
||||
<h4>3. 개인정보의 보유 및 이용기간</h4>
|
||||
<p>회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)</p>
|
||||
|
||||
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
|
||||
<p>개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-agreement-checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={handleAgreementChange}
|
||||
/>
|
||||
<span>개인정보 수집 및 이용에 동의합니다.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-agreement-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="user-signup-cancel-button"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextStep}
|
||||
className="user-signup-next-button"
|
||||
disabled={!agreed}
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="user-signup-form">
|
||||
<div className="user-signup-form-section">
|
||||
<h3>기본 정보</h3>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="userId">아이디 *</label>
|
||||
<div className="user-signup-input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
id="userId"
|
||||
name="userId"
|
||||
value={formData.userId}
|
||||
onChange={handleInputChange}
|
||||
placeholder="아이디를 입력하세요"
|
||||
maxLength={12}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkUserIdDuplicate}
|
||||
disabled={isCheckingId || isLoading || !formData.userId}
|
||||
className="user-signup-check-button"
|
||||
>
|
||||
{isCheckingId ? "확인중..." : "중복확인"}
|
||||
</button>
|
||||
</div>
|
||||
{idAvailable === true && <span className="user-signup-success-message">사용 가능한 아이디입니다.</span>}
|
||||
{errors.userId && <span className="user-signup-error-message">{errors.userId}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="userName">이름 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="userName"
|
||||
name="userName"
|
||||
value={formData.userName}
|
||||
onChange={handleInputChange}
|
||||
placeholder="이름을 입력하세요"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.userName && <span className="user-signup-error-message">{errors.userName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="email">이메일</label>
|
||||
<div className="user-signup-input-with-button">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="이메일을 입력하세요"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkEmailDuplicate}
|
||||
disabled={isCheckingEmail || isLoading || !formData.email}
|
||||
className="user-signup-check-button"
|
||||
>
|
||||
{isCheckingEmail ? "확인중..." : "중복확인"}
|
||||
</button>
|
||||
</div>
|
||||
{emailAvailable === true && <span className="user-signup-success-message">사용 가능한 이메일입니다.</span>}
|
||||
{errors.email && <span className="user-signup-error-message">{errors.email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="password">비밀번호 *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="8~20자, 영문/숫자/특수기호 포함"
|
||||
maxLength={20}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.password && <span className="user-signup-error-message">{errors.password}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="비밀번호를 다시 입력하세요"
|
||||
maxLength={20}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.confirmPassword && <span className="user-signup-error-message">{errors.confirmPassword}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
|
||||
<AnonymousCodeSelect
|
||||
upCd="PSWDHNT"
|
||||
value={formData.pswdHnt}
|
||||
onValueChange={(value: string) => setFormData(prev => ({ ...prev, pswdHnt: value }))}
|
||||
disabled={isLoading}
|
||||
placeholder="비밀번호 힌트를 선택하세요"
|
||||
/>
|
||||
{errors.pswdHnt && <span className="user-signup-error-message">{errors.pswdHnt}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="pswdAns">비밀번호 답변</label>
|
||||
<input
|
||||
type="text"
|
||||
id="pswdAns"
|
||||
name="pswdAns"
|
||||
value={formData.pswdAns}
|
||||
onChange={handleInputChange}
|
||||
placeholder="비밀번호 답변을 입력하세요"
|
||||
maxLength={100}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.pswdAns && <span className="user-signup-error-message">{errors.pswdAns}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-section">
|
||||
<h3>연락처 정보</h3>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="mphn">휴대폰 번호</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="mphn"
|
||||
name="mphn"
|
||||
value={formData.mphn}
|
||||
onChange={handleInputChange}
|
||||
placeholder="휴대폰 번호를 입력하세요"
|
||||
maxLength={11}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.mphn && <span className="user-signup-error-message">{errors.mphn}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="telno">전화번호</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="telno"
|
||||
name="telno"
|
||||
value={formData.telno}
|
||||
onChange={handleInputChange}
|
||||
placeholder="전화번호를 입력하세요"
|
||||
maxLength={11}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.telno && <span className="user-signup-error-message">{errors.telno}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-section">
|
||||
<h3>주소 정보</h3>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="zip">우편번호</label>
|
||||
<input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
value={formData.zip}
|
||||
onChange={handleInputChange}
|
||||
placeholder="우편번호를 입력하세요"
|
||||
maxLength={5}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.zip && <span className="user-signup-error-message">{errors.zip}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="addr">주소</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr"
|
||||
name="addr"
|
||||
value={formData.addr}
|
||||
onChange={handleInputChange}
|
||||
placeholder="주소를 입력하세요"
|
||||
maxLength={200}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.addr && <span className="user-signup-error-message">{errors.addr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="daddr">상세주소</label>
|
||||
<input
|
||||
type="text"
|
||||
id="daddr"
|
||||
name="daddr"
|
||||
value={formData.daddr}
|
||||
onChange={handleInputChange}
|
||||
placeholder="상세주소를 입력하세요"
|
||||
maxLength={200}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.daddr && <span className="user-signup-error-message">{errors.daddr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-section">
|
||||
<h3>기관 정보</h3>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="laeSeCd">법인구분 *</label>
|
||||
<AnonymousCodeSelect
|
||||
upCd="LAESECD"
|
||||
value={formData.laeSeCd}
|
||||
onValueChange={(value: string) => setFormData(prev => ({ ...prev, laeSeCd: value }))}
|
||||
disabled={isLoading}
|
||||
placeholder="법인구분을 선택하세요"
|
||||
/>
|
||||
{errors.laeSeCd && <span className="user-signup-error-message">{errors.laeSeCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="ogdpSeCd">기관구분 *</label>
|
||||
<AnonymousCodeSelect
|
||||
upCd="OGDPSECD"
|
||||
value={formData.ogdpSeCd}
|
||||
onValueChange={(value: string) => setFormData(prev => ({ ...prev, ogdpSeCd: value }))}
|
||||
disabled={isLoading}
|
||||
placeholder="기관구분을 선택하세요"
|
||||
/>
|
||||
{errors.ogdpSeCd && <span className="user-signup-error-message">{errors.ogdpSeCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="instSeCd">기관코드</label>
|
||||
<input
|
||||
type="text"
|
||||
id="instSeCd"
|
||||
name="instSeCd"
|
||||
value={formData.instSeCd}
|
||||
onChange={handleInputChange}
|
||||
placeholder="기관코드를 입력하세요"
|
||||
maxLength={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.instSeCd && <span className="user-signup-error-message">{errors.instSeCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-form-group">
|
||||
<label htmlFor="dtlInstCd">상세기관코드</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dtlInstCd"
|
||||
name="dtlInstCd"
|
||||
value={formData.dtlInstCd}
|
||||
onChange={handleInputChange}
|
||||
placeholder="상세기관코드를 입력하세요"
|
||||
maxLength={10}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.dtlInstCd && <span className="user-signup-error-message">{errors.dtlInstCd}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="user-signup-form-row">
|
||||
<div className="user-signup-error-message user-signup-submit-error">{errors.submit}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="user-signup-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep('agreement')}
|
||||
className="user-signup-back-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="user-signup-cancel-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="user-signup-submit-button"
|
||||
disabled={isLoading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
console.log("회원가입 버튼 클릭됨 - onClick");
|
||||
handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
{isLoading ? "가입 중..." : "회원가입"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserSignupModal;
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="login fx">
|
||||
@@ -214,6 +230,28 @@ function LoginPage() {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="success-message" style={{ color: "green", textAlign: "center", marginTop: "10px" }}>
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="signup-link" style={{ textAlign: "center", marginTop: "15px" }}>
|
||||
<span style={{ color: "#666", fontSize: "14px" }}>계정이 없으신가요? </span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSignupModal(true)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#3b82f6",
|
||||
textDecoration: "underline",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px"
|
||||
}}
|
||||
>
|
||||
회원가입
|
||||
</button>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<div className="copy fx">
|
||||
<p>copyright © 2023 KECO. All rights reserved.</p>
|
||||
@@ -269,6 +307,13 @@ function LoginPage() {
|
||||
open={showTwoFAModal}
|
||||
onOpenChange={setShowTwoFAModal}
|
||||
/>
|
||||
|
||||
{/* 회원가입 모달 */}
|
||||
<UserSignupModal
|
||||
onClose={handleSignupClose}
|
||||
onSuccess={handleSignupSuccess}
|
||||
open={showSignupModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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<Zip>;
|
||||
|
||||
export class ZipApiService {
|
||||
public async searchZip(params: ZipSearchParams): Promise<ZipList> {
|
||||
const response = await client.get<ZipList>(`${ENDPOINT.CMS_SERVICE.ZIPS}`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const ZipApi = new ZipApiService();
|
||||
@@ -0,0 +1 @@
|
||||
export { CreatePage } from "./ui/create-page";
|
||||
@@ -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<UserListResponse>[] = [
|
||||
{
|
||||
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 <Badge color="gray">대기</Badge>;
|
||||
case USER_STATE_CODE.정상:
|
||||
return <Badge color="green">정상</Badge>;
|
||||
case USER_STATE_CODE.정지:
|
||||
return <Badge color="yellow">정지</Badge>;
|
||||
case USER_STATE_CODE.탈퇴:
|
||||
return <Badge color="purple">탈퇴</Badge>;
|
||||
case USER_STATE_CODE.삭제:
|
||||
return <Badge color="red">삭제</Badge>;
|
||||
}
|
||||
};
|
||||
@@ -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<LwpoCode | null>(null);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState<LwpoCodeParams>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="px-6">기관 검색</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-h-[80vh] max-w-[60rem] overflow-auto"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>기관 검색</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSearch();
|
||||
}}
|
||||
>
|
||||
<div className="card card-border !mx-0 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap">기관명</Label>
|
||||
<Input
|
||||
value={searchQuery.lwpoCdNm ?? ""}
|
||||
onChange={(e) =>
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
lwpoCdNm: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="기관명을 입력하세요"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
<Button type="submit">검색</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{tableComponent}
|
||||
<DialogFooter className="!justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSelectAgency}
|
||||
disabled={!selectedAgency}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button size="lg" color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AgencySearchDialog };
|
||||
@@ -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<UserSaveData>({
|
||||
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 (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">회원 등록</h3>
|
||||
</div>
|
||||
<UsersForm form={form} handleSave={saveUser} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CreatePage };
|
||||
@@ -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<UserUpdateData>({
|
||||
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 (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">회원 정보 수정</h3>
|
||||
</div>
|
||||
<UsersForm
|
||||
userId={userData?.userId}
|
||||
form={form}
|
||||
handleSave={updateUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">회원 정보</h3>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="mb-4 flex justify-end gap-2">
|
||||
{data?.userStateCode === USER_STATE_CODE.정지 ? (
|
||||
<Button color="orange" onClick={() => onUnlockUser()}>
|
||||
<i className="diveicon di-lock-open" />
|
||||
잠금해제
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="orange" onClick={() => onLockUser()}>
|
||||
<i className="diveicon di-lock" />
|
||||
계정잠금
|
||||
</Button>
|
||||
)}
|
||||
<PasswordResetDialog userUnqId={userUnqId}>
|
||||
<Button color="gray">
|
||||
<i className="diveicon di-arrow-reset" /> 비밀번호 초기화
|
||||
</Button>
|
||||
</PasswordResetDialog>
|
||||
{data?.userStateCode === USER_STATE_CODE.대기 && (
|
||||
<Button className="emerald" onClick={() => onApplyUser()}>
|
||||
<i className="diveicon di-check-circle-line" /> 가입승인
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="80%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>아이디</TableHead>
|
||||
<TableCell className="!px-4">{data?.userId}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableCell className="!px-4">{data?.userName}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>연락처</TableHead>
|
||||
<TableCell className="!px-4">휴대폰: {data?.mphn}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="!px-4">전화번호: {data?.telno}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell className="!px-4">{data?.email}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>주소</TableHead>
|
||||
<TableCell className="!px-4">
|
||||
{data?.zip && `(${data?.zip})`} {data?.addr}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="!px-4">{data?.daddr}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>기관구분</TableHead>
|
||||
<TableCell className="!px-4">{data?.ogdpSeNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>세부기관</TableHead>
|
||||
<TableCell className="!px-4">{data?.dtlInstNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>소속</TableHead>
|
||||
<TableCell className="!px-4">{data?.laeSeNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>환경청</TableHead>
|
||||
<TableCell className="!px-4">{data?.ecoNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>가입일</TableHead>
|
||||
<TableCell className="!px-4">
|
||||
{data?.regDt ? dayjs(data?.regDt).format("YYYY-MM-DD") : ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>최근로그인</TableHead>
|
||||
<TableCell className="!px-4">
|
||||
{data?.lastLoginDate
|
||||
? dayjs(data?.lastLoginDate).format("YYYY-MM-DD HH:mm:ss")
|
||||
: ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>계정상태</TableHead>
|
||||
<TableCell className="!px-4">{data?.userStateNm}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4 pb-12 pt-10">
|
||||
<Button size="lg" className="white" onClick={router.back}>
|
||||
목록
|
||||
</Button>
|
||||
<Button size="lg" onClick={() => router.push(`${pathname}/modify`)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button size="lg" color="red" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { UserDetail };
|
||||
@@ -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<T extends UserSaveData | UserUpdateData> = {
|
||||
userId?: string;
|
||||
form: UseFormReturn<T>;
|
||||
handleSave: (data: T) => Promise<string>;
|
||||
handleDelete?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function UsersForm<T extends UserSaveData | UserUpdateData>({
|
||||
userId,
|
||||
form,
|
||||
...props
|
||||
}: UsersFormProps<T>) {
|
||||
const isModifyMode = !!userId;
|
||||
|
||||
const [watchOgdpSeCd, watchDtlInstCd] = useWatch({
|
||||
control: form.control,
|
||||
name: ["ogdpSeCd" as Path<T>, "dtlInstCd" as Path<T>],
|
||||
});
|
||||
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<T>);
|
||||
setPasswordMatch(!password || value === password);
|
||||
}, []);
|
||||
|
||||
const handleAddressSelect = useCallback(
|
||||
(data: { zip: string; addr: string }) => {
|
||||
form.setValue("zip" as Path<T>, data.zip as PathValue<T, Path<T>>);
|
||||
form.setValue("addr" as Path<T>, data.addr as PathValue<T, Path<T>>);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleAgencySelect = useCallback(
|
||||
(data: { lwpoCd: string; lwpoCdNm: string }) => {
|
||||
form.setValue(
|
||||
"instSeCd" as Path<T>,
|
||||
data.lwpoCd as PathValue<T, Path<T>>,
|
||||
);
|
||||
setInstSeCdNm(data.lwpoCdNm);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormWrapper form={form} isModifyMode={isModifyMode} {...props}>
|
||||
<div className="card">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="80%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
{/* 아이디 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>아이디</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"userId" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setIsValidUserId(false);
|
||||
}}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="아이디를 입력하세요"
|
||||
disabled={isModifyMode}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
{!isModifyMode && (
|
||||
<Button
|
||||
color="green"
|
||||
className="px-6"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
checkDuplicateUserId(field.value as string);
|
||||
}}
|
||||
disabled={!field.value || isValidUserId}
|
||||
>
|
||||
{isValidUserId && <i className="diveicon di-check" />}
|
||||
중복확인
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>비밀번호</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"password" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<TableRow>
|
||||
<TableHead>비밀번호 확인</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(e) =>
|
||||
handlePasswordConfirmChange(e.target.value)
|
||||
}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="비밀번호를 다시 입력하세요"
|
||||
/>
|
||||
{!passwordMatch && (
|
||||
<p className="mt-2 text-base text-red-500">
|
||||
비밀번호가 일치하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 힌트 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>비밀번호 힌트</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"pswdHnt" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CodeSelect
|
||||
{...field}
|
||||
upCd="PSWDHNT"
|
||||
className="w-[24rem]"
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 정답 */}
|
||||
<TableRow>
|
||||
<TableHead>비밀번호 정답</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"pswdAns" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="비밀번호 정답을 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 이름 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>이름</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"userName" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="이름을 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 연락처 */}
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>연락처</TableHead>
|
||||
<TableCell>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"mphn" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="휴대폰 번호(-를 제외하고 입력)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"telno" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="전화번호(-를 제외하고 입력)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 이메일 */}
|
||||
<TableRow>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"email" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setIsValidEmail(false);
|
||||
}}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="이메일을 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<Button
|
||||
color="green"
|
||||
className="px-6"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (field.value) {
|
||||
checkDuplicateEmail(field.value as string);
|
||||
}
|
||||
}}
|
||||
disabled={!field.value || isValidEmail}
|
||||
>
|
||||
{isValidEmail && <i className="diveicon di-check" />}
|
||||
중복확인
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 주소 */}
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>주소</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"zip" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[10rem] !text-[1.3rem]"
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ZipSearchDialog onSelectAddress={handleAddressSelect} />
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"addr" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[36rem] !text-[1.3rem]"
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"daddr" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[36rem] !text-[1.3rem]"
|
||||
placeholder="상세 주소를 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 기관구분 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>기관구분</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"ogdpSeCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CodeSelect
|
||||
{...field}
|
||||
upCd="OGDPSECD"
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue(
|
||||
"dtlInstCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
form.setValue(
|
||||
"laeSeCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
form.setValue(
|
||||
"enoCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
}}
|
||||
className="w-[16rem]"
|
||||
placeholder="기관 선택"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 세부기관 */}
|
||||
<TableRow>
|
||||
<TableHead>세부기관</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"dtlInstCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue(
|
||||
"laeSeCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
form.setValue(
|
||||
"enoCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[16rem]">
|
||||
<SelectValue placeholder="세부기관 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{authSeCdSelectData?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 소속 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>소속</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"laeSeCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue(
|
||||
"enoCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[16rem]">
|
||||
<SelectValue placeholder="소속 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{laeSeCdSelectData?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 환경청 */}
|
||||
<TableRow>
|
||||
<TableHead>환경청</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"enoCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
disabled={
|
||||
!(
|
||||
watchDtlInstCd === "ROLE_USER4" ||
|
||||
watchDtlInstCd === "ROLE_USER5"
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[16rem]">
|
||||
<SelectValue placeholder="환경청 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enoCdSelectData?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 환경청 기관/업체명 */}
|
||||
<TableRow>
|
||||
<TableHead>기관/업체명</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={instSeCdNm}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
disabled
|
||||
/>
|
||||
<AgencySearchDialog onSelectAgency={handleAgencySelect} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</FormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export { UsersForm };
|
||||
@@ -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<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [presetDateRange, setPresetDateRange] = useState("");
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
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<UserSearchParams>({
|
||||
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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card card-border flex items-center justify-center gap-10 !bg-accent !py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-[rgba(153,153,153,1)]">
|
||||
<i className="diveicon di-person text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
승인대기{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("00")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary">
|
||||
<i className="diveicon di-person-fill text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
전체{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("TOTAL")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-[rgba(255,149,0,1)]">
|
||||
<i className="diveicon di-lock text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
잠금{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("07")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive">
|
||||
<i className="diveicon di-exclamation-triangle-fill text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
탈퇴{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("08")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-border">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 p-2">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">
|
||||
회원정보
|
||||
</Label>
|
||||
<Select
|
||||
value={keywordType}
|
||||
onValueChange={(value) => setKeywordType(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[18rem]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="userId">아이디</SelectItem>
|
||||
<SelectItem value="userName">이름</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="mphn">휴대폰번호</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="!text-[1.3rem]"
|
||||
placeholder="검색어를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 text-label">그룹</Label>
|
||||
{/* Select */}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 text-label">기간</Label>
|
||||
<Select
|
||||
value={presetDateRange}
|
||||
onValueChange={handlePresetDateRange}
|
||||
>
|
||||
<SelectTrigger className="w-[12rem]">
|
||||
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1M">1개월</SelectItem>
|
||||
<SelectItem value="3M">3개월</SelectItem>
|
||||
<SelectItem value="6M">6개월</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputDateRangePicker
|
||||
dateRange={dateRange}
|
||||
setDateRange={handleDateRangeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 text-label">회원상태</Label>
|
||||
<div className="flex gap-4">
|
||||
{userStateCodes?.map((item) => (
|
||||
<div key={item.lwpoCd} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={item.lwpoCd}
|
||||
value={item.lwpoCd}
|
||||
checked={userStateCode?.includes(item.lwpoCd)}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUserStateCodeChange(item.lwpoCd, !!checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={item.lwpoCd}>{item.lwpoCdNm}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="submit" className="w-[16rem]">
|
||||
<i className="diveicon di-search" />
|
||||
검색
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[16rem]"
|
||||
onClick={handleReset}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="text-2xl font-medium text-label">회원 목록</h3>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
defaultValue="regDt,desc"
|
||||
onValueChange={(value) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
sort: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="aprvYmd,desc">승인일순</SelectItem>
|
||||
<SelectItem value="regDt,desc">등록일순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={table.getState().pagination.pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(parseInt(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-[3.2rem] w-[12rem]">
|
||||
<SelectValue placeholder="페이지 크기" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={pageSize.toString()}>
|
||||
{pageSize}개씩 보기
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => router.push("/users/register")}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { UsersPage };
|
||||
@@ -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<Zip | null>(null);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="px-6">우편번호 검색</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>주소 검색</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSearch();
|
||||
}}
|
||||
className="mb-4 space-y-2"
|
||||
>
|
||||
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">도로명</Label>
|
||||
<Input
|
||||
value={searchQuery.roadNm || ""}
|
||||
onChange={(e) =>
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
roadNm: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: 테헤란로"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">지번본번</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={searchQuery.lotnoMno ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null;
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
lotnoMno: value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: 123"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">지번부번</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={searchQuery.lotnoSno ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null;
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
lotnoSno: value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: 45"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">검색</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{query.data?.content.length && tableComponent}
|
||||
|
||||
<DialogFooter className="!justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSelectAddress}
|
||||
disabled={!selectedAddress}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button size="lg" color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ZipSearchDialog };
|
||||
@@ -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<T = unknown, R = AxiosResponse<T>>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.get<T, R>(url, config);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async post<T = unknown, R = AxiosResponse<T>, D = unknown>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.post<T, R>(url, data, config);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async put<T = unknown, R = AxiosResponse<T>, D = unknown>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.put<T, R>(url, data, config);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async delete<T = unknown, R = AxiosResponse<T>>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.delete<T, R>(url, config);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export type { SignupClientConfig };
|
||||
export { SignupClient };
|
||||
Reference in New Issue
Block a user