Merge branch 'iipbs/sn/dev' into iipbs/sn/ii
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,901 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SignupClient } from "@/shared/api/signup-client";
|
||||
import { BASE_URL, ENDPOINT } from "@/shared/config";
|
||||
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 };
|
||||
@@ -25,6 +25,7 @@ export const ENDPOINT = {
|
||||
OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify",
|
||||
TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup",
|
||||
TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable",
|
||||
USER_JOIN: "/user-service/api/v1/anonymouse/users/join",
|
||||
},
|
||||
CMS_SERVICE: {
|
||||
DASHBOARD: "/cms-service/api/v1/dashboard",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"sonner": "^2.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"yargs": "^18.0.0",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ const getRuseFclt = async (
|
||||
searchPk: item.bscFctNm,
|
||||
};
|
||||
})
|
||||
.filter((hasu) => ruseList.find((ruse) => hasu.bscFctCd === ruse));
|
||||
.filter((hasu:any) => ruseList.find((ruse:any) => hasu.bscFctCd === ruse));
|
||||
return { data: data, count: res.count };
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"use client"
|
||||
import "./globals.css";
|
||||
import { StoreProvider } from "./store";
|
||||
import { LoginApi } from "@/src/entities/auth";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
useEffect(() => {
|
||||
// 렌더 중이 아니라 클라이언트에서만 실행 + 에러 캐치
|
||||
void LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" })
|
||||
.catch((e) => {
|
||||
console.error("Auto login failed:", e);
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
'use client'
|
||||
import { NoFooterLayout } from "@common/components";
|
||||
import type { MenuItem } from "../../store/common";
|
||||
import { useFoStore } from "../../store";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
const { getMenuAuth } = useFoStore((state) => state);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (id: MenuItem) => {
|
||||
getMenuAuth(id.menuCd);
|
||||
router.push(id.urlPath);
|
||||
};
|
||||
return (
|
||||
<div>{children}</div>
|
||||
<NoFooterLayout currentPath={pathname ?? ""} handleMenuClick={handleClick}>{children}</NoFooterLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,19 +3,35 @@
|
||||
import Gis from "@common/business_components/ui/map/Gis";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SearchBar } from "../../map/searchBar/searchBar";
|
||||
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
|
||||
|
||||
// OpenLayers overlay/extent 유틸
|
||||
import Overlay from "ol/Overlay";
|
||||
import { getCenter as getExtentCenter } from "ol/extent";
|
||||
import type { Coordinate } from "ol/coordinate";
|
||||
|
||||
interface FeatureData {
|
||||
fid?: string;
|
||||
ftrIdn?: string;
|
||||
id?: string;
|
||||
getGeometry?: () => { getExtent: () => number[] };
|
||||
getId?: () => string | number | undefined; // 추가
|
||||
// OpenLayers Feature 비슷한 최소 인터페이스
|
||||
get?: (key: string) => any;
|
||||
getGeometry?: () => {
|
||||
getExtent: () => number[];
|
||||
getType?: () => string;
|
||||
getCoordinates?: () => Coordinate;
|
||||
};
|
||||
setStyle?: () => void;
|
||||
}
|
||||
|
||||
interface MapInstance {
|
||||
addOverlay?: (ov: Overlay) => void;
|
||||
removeOverlay?: (ov: Overlay) => void;
|
||||
getOverlays?: () => { getArray: () => Overlay[] };
|
||||
getLayers: () => {
|
||||
getArray: () => Array<{
|
||||
get: (key: string) => any;
|
||||
getSource?: () => { getFeatureById?: (id: string) => FeatureData | null };
|
||||
}>;
|
||||
};
|
||||
@@ -23,11 +39,19 @@ interface MapInstance {
|
||||
}
|
||||
|
||||
const Map = () => {
|
||||
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
|
||||
const [treeData, setTreeData] = useState([]);
|
||||
const gisMaps = useRef<MapInstance | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태
|
||||
const [featureData, setFeatureData] = useState<FeatureData | null>(null); // 선택된 피쳐 데이터
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
const [featureData, setFeatureData] = useState<FeatureData | null>(null);
|
||||
|
||||
// === Overlay 관리용 Ref ===
|
||||
const overlayRef = useRef<Overlay | null>(null);
|
||||
const overlayContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const facilityTypes = [
|
||||
{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" },
|
||||
];
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -35,28 +59,127 @@ const Map = () => {
|
||||
const reqUrl = `${API_URL}/lyrStngs/FO_NETWORK`;
|
||||
const response = await fetch(reqUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP 오류! 상태: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json(); // 여기서 json 파싱
|
||||
setTreeData(data); // 데이터 설정
|
||||
if (!response.ok) throw new Error(`HTTP 오류! 상태: ${response.status}`);
|
||||
const data = await response.json();
|
||||
setTreeData(data);
|
||||
} catch (error) {
|
||||
console.error("데이터 불러오기 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 1회 실행
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// SearchModal, GisContent 등에서 호출
|
||||
function formatWithCommas(value: any) {
|
||||
if (value === null || value === undefined) return value;
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? value : num.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDateString(dateStr: string) {
|
||||
dateStr = dateStr.trim();
|
||||
if (typeof dateStr !== "string" || dateStr.length !== 8) return dateStr;
|
||||
return `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6)}`;
|
||||
}
|
||||
|
||||
// ===== 오버레이 콘텐츠 빌더 =====
|
||||
const buildOverlayContent = (layerType: string, feature: FeatureData) => {
|
||||
const props: Record<string, any> = {};
|
||||
// 안전하게 몇 개 필드 추출(없으면 건너뜀)
|
||||
const mayGet = (k: string) => (feature.get ? feature.get(k) : undefined);
|
||||
const safe = (v: any) => (v === undefined || v === null ? "-" : String(v));
|
||||
|
||||
// 레이어 유형별 대표 필드 추출(없으면 "-")
|
||||
if (layerType === "tb_il_swage_prcs_fclt") {
|
||||
// 하수처리시설
|
||||
props["하수처리시설명"] = mayGet("swtrmt_fclt_nm");
|
||||
props["시설용량(m³/d)"] = formatWithCommas(mayGet("fclt_cpct"));
|
||||
props["주소"] = mayGet("rdnm_addr");
|
||||
props["가동개시일"] = formatDateString(mayGet("oprtng_strt_ymd"));
|
||||
props["공법"] = mayGet("rprs_msrc_cn");
|
||||
} else if (layerType === "tb_ms_swage_prcs_zone") {
|
||||
// 처리구역(존)
|
||||
props["하수처리구역명"] = mayGet("swtrmt_zone_nm");
|
||||
props["하수처리시설명"] = mayGet("swtrmt_fclt_nm");
|
||||
} else if (layerType === "om_wqlt_station") {
|
||||
// 수질관측소
|
||||
props["관측소명"] = mayGet("stn_ko") ?? mayGet("name");
|
||||
props["주소"] = mayGet("addr") ?? mayGet("indicator");
|
||||
}
|
||||
|
||||
const titleMap: Record<string, string> = {
|
||||
tb_il_swage_prcs_fclt: "하수처리시설",
|
||||
tb_ms_swage_prcs_zone: "하수처리구역",
|
||||
om_wqlt_station: "관측소",
|
||||
};
|
||||
const title = titleMap[layerType] || "정보";
|
||||
|
||||
// 컨테이너 재사용(없으면 생성)
|
||||
if (!overlayContainerRef.current) {
|
||||
overlayContainerRef.current = document.createElement("div");
|
||||
}
|
||||
const el = overlayContainerRef.current;
|
||||
el.className =
|
||||
"ol-custom-overlay shadow-xl rounded-2xl bg-white/95 backdrop-blur p-3 border border-gray-200 min-w-[220px] max-w-[350px] ";
|
||||
el.innerHTML = `
|
||||
<div class="font-semibold text-lg text-gray-800 mb-2 text-center bg-green-400">${title}</div>
|
||||
<table class="w-full text-base text-gray-800">
|
||||
${Object.entries(props)
|
||||
.map(
|
||||
([k, v]) => `
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-gray-500 whitespace-nowrap">${k}</td>
|
||||
<td class="py-1 font-medium">${safe(v)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")}
|
||||
</table>
|
||||
`;
|
||||
return el;
|
||||
};
|
||||
|
||||
// ===== 오버레이 생성/표시 =====
|
||||
const showOverlay = (
|
||||
map: MapInstance,
|
||||
feature: FeatureData,
|
||||
layerType: string
|
||||
) => {
|
||||
// 이전 오버레이 제거
|
||||
if (overlayRef.current) {
|
||||
map.removeOverlay?.(overlayRef.current);
|
||||
overlayRef.current = null;
|
||||
}
|
||||
|
||||
// 콘텐츠 만들기
|
||||
const element = buildOverlayContent(layerType, feature);
|
||||
|
||||
// 앵커 좌표 계산(포인트면 좌표, 아니면 extent 중심)
|
||||
let position: Coordinate | undefined;
|
||||
const geom = feature.getGeometry?.();
|
||||
if (geom?.getType && geom.getType() === "Point" && geom.getCoordinates) {
|
||||
position = geom.getCoordinates();
|
||||
} else if (geom?.getExtent) {
|
||||
position = getExtentCenter(geom.getExtent());
|
||||
}
|
||||
|
||||
if (!position) return;
|
||||
|
||||
// 오버레이 생성 & 지도에 추가
|
||||
const overlay = new Overlay({
|
||||
element,
|
||||
positioning: "bottom-center",
|
||||
stopEvent: false,
|
||||
offset: [0, -12],
|
||||
});
|
||||
overlay.setPosition(position);
|
||||
map.addOverlay?.(overlay);
|
||||
overlayRef.current = overlay;
|
||||
};
|
||||
|
||||
// ===== 피처 선택 시 호출 =====
|
||||
const onFeatureSelect = (data: FeatureData) => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) {
|
||||
@@ -64,48 +187,55 @@ const Map = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 항상 같은 select 객체 사용
|
||||
const select = GisApp.Module.select as unknown as
|
||||
| {
|
||||
type SelectModule = {
|
||||
resetSelectedFeature: () => void;
|
||||
selectFeature: (feature: FeatureData, selected: boolean) => void;
|
||||
};
|
||||
|
||||
const maybeSelect = GisApp?.Module?.select; // 타입 그대로 둠 (any | unknown | null 가능)
|
||||
|
||||
if (!maybeSelect) {
|
||||
// null/undefined면 일찍 반환
|
||||
return;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const select = maybeSelect as SelectModule; // 여기선 null 아님이 보장됨
|
||||
if (!select) return;
|
||||
|
||||
// 1. 기존 선택 초기화
|
||||
// 1) 기존 선택 초기화
|
||||
select.resetSelectedFeature();
|
||||
|
||||
// 2. data가 OpenLayers Feature 인스턴스인지 확인
|
||||
// 2) 피처 탐색 + 레이어 유형 파악
|
||||
const featureId = String(data.getId?.() ?? "");
|
||||
const foundLayerType = featureId.split(".")[0];
|
||||
let foundFeature: FeatureData | null = null;
|
||||
if (
|
||||
const isOlFeatureInstance =
|
||||
data &&
|
||||
typeof data.getGeometry === "function" &&
|
||||
typeof data.setStyle === "function"
|
||||
) {
|
||||
// 이미 Feature 인스턴스라면 그대로 사용
|
||||
typeof data.setStyle === "function";
|
||||
|
||||
if (isOlFeatureInstance) {
|
||||
foundFeature = data;
|
||||
// 어떤 레이어에서 온 건지 모르는 경우가 많아 기본값으로 시도
|
||||
} else {
|
||||
// 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용
|
||||
const featureId = data.fid || data.ftrIdn || data.id;
|
||||
map
|
||||
.getLayers()
|
||||
.getArray()
|
||||
.forEach((layer) => {
|
||||
if (foundFeature) return; // 이미 찾았으면 skip
|
||||
const source = layer.getSource?.();
|
||||
if (source?.getFeatureById) {
|
||||
const feature = source.getFeatureById(String(featureId).trim());
|
||||
if (feature) foundFeature = feature;
|
||||
const feature = source?.getFeatureById?.(featureId);
|
||||
if (feature) {
|
||||
foundFeature = feature;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 피처 선택
|
||||
// 3) 피처 선택/줌 + 오버레이
|
||||
if (foundFeature && foundFeature.getGeometry) {
|
||||
const geometry = foundFeature.getGeometry();
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
|
||||
map.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
@@ -115,15 +245,41 @@ const Map = () => {
|
||||
});
|
||||
}
|
||||
select.selectFeature(foundFeature, true);
|
||||
|
||||
// 표출 대상 레이어만 오버레이 표시
|
||||
const normalized = (foundLayerType || "").toString().trim();
|
||||
const TARGETS = [
|
||||
"tb_il_swage_prcs_fclt",
|
||||
"tb_ms_swage_prcs_zone",
|
||||
"om_wqlt_station",
|
||||
];
|
||||
|
||||
if (TARGETS.includes(normalized)) {
|
||||
showOverlay(map, foundFeature, normalized);
|
||||
} else {
|
||||
// 대상 외 레이어 클릭 시 기존 오버레이 제거
|
||||
if (overlayRef.current) {
|
||||
map.removeOverlay?.(overlayRef.current);
|
||||
overlayRef.current = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("OpenLayers Feature를 찾을 수 없습니다:", data);
|
||||
// 실패 시 기존 오버레이 제거
|
||||
if (overlayRef.current) {
|
||||
map.removeOverlay?.(overlayRef.current);
|
||||
overlayRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 우측 패널/검색 모달 상태
|
||||
setFeatureData(data);
|
||||
setIsOpen(true);
|
||||
setIsOpen(false);
|
||||
setIsSearchModalOpen(false);
|
||||
};
|
||||
|
||||
// (선택) 다른 위치 클릭 시 오버레이 닫고 싶다면 Gis 쪽에서 지도 클릭 이벤트 받아 여기서 제거하는 훅을 연결하면 됨.
|
||||
|
||||
return (
|
||||
<div className="bg-container">
|
||||
<Gis
|
||||
@@ -142,7 +298,9 @@ const Map = () => {
|
||||
isSearchModalOpen={isSearchModalOpen}
|
||||
onCheckedLayersChange={undefined}
|
||||
imgVisble={false}
|
||||
searchBar={() => <SearchBar onFeatureSelect={onFeatureSelect} gisMaps={gisMaps} />}
|
||||
searchBar={
|
||||
<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />
|
||||
}
|
||||
waterDepth={undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -219,7 +219,6 @@ type SetStateFunction = (state: RdiType | ((state: RdiType) => RdiType)) => void
|
||||
export type RdiStore = RdiType & RdiActions;
|
||||
|
||||
export const rdiReducer:(set:SetStateFunction)=>RdiActions=(set: SetStateFunction) => {
|
||||
console.log("tableActionsExport");
|
||||
return {
|
||||
searchRdiList: async (pageNo: number, pageSize: number, searchYear: string, partCd?: string, nameCd?: string, fcltCpct?: string, operationValue?: string, fcltyNm?: string) => {
|
||||
set((state: RdiType) => ({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { LoginApi } from '@/src/entities/auth/api/login-service';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
import { CommonReducer, commonState, type CommonStore, type CommonType } from './common';
|
||||
import { dashBoardInitState, dashBoardReducer, type DashBoardStore, type DashBoardType } from './dashboard';
|
||||
@@ -59,7 +58,6 @@ export const StoreZus =
|
||||
}
|
||||
) => {
|
||||
return createStore<FoStore>()((set) => {
|
||||
LoginApi.login({userId: "CLEANWATER", password: "gktneh12!@"})
|
||||
return {
|
||||
...initState,
|
||||
...CommonReducer(set),
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"next": "15.1.7",
|
||||
"select": "^1.1.2",
|
||||
"webpack": "^5.98.0",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^5.0.3",
|
||||
"ol": "^7.3.0",
|
||||
"ol-ext": "^4.0.11",
|
||||
"proj4": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -28,9 +28,6 @@ export class LoginApiService implements LoginService {
|
||||
const accessToken = response.headers.get(ACCESS_TOKEN);
|
||||
const tokenId = response.headers.get(AUTH_USER_ID);
|
||||
|
||||
console.log('accessToken ', accessToken)
|
||||
console.log('tokenId ', tokenId)
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ NEXT_PUBLIC_PORT = 11302
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
||||
NEXT_PUBLIC_ADDR_URL = http://con.mind-one.co.kr:11082
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
# NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
NEXT_PUBLIC_CLAIM_NAME = Authorization
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@ NEXT_PUBLIC_PORT = 3002
|
||||
NEXT_PUBLIC_PROXY_HOST = http://localhost:3002
|
||||
NEXT_PUBLIC_ADDR_URL = http://con.mind-one.co.kr:11082
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
# NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
NEXT_PUBLIC_CLAIM_NAME = Authorization
|
||||
|
||||
@@ -7,7 +7,8 @@ NEXT_PUBLIC_PORT = 3000
|
||||
NEXT_PUBLIC_PROXY_HOST = https://sn.hasuon.or.kr
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_ADDR_URL = http://con.mind-one.co.kr:11082
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
# NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
NEXT_PUBLIC_NGII_URL = http://10.98.25.94:18080/openapi/Gettile.do?apiKey=BDE2BEF39DAFEC293AB6730150F222AC
|
||||
|
||||
@@ -42,27 +42,15 @@ const Map = () => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bc = new BroadcastChannel("mapDetailPopUp");
|
||||
bc.onmessage = (event) => {
|
||||
if (event.data.type === "SELECT_FEATURE") {
|
||||
onFeatureSelect(event.data.feature);
|
||||
closePopup();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.data.type === "POPUP_CLOSED") {
|
||||
if (event.data.target === "detail") {
|
||||
console.log("디테일 팝업 닫힘 감지됨");
|
||||
setFeatureData(null);
|
||||
setIsOpen(false);
|
||||
setIsSearchModalOpen(true);
|
||||
popupDetailRef.current = null;
|
||||
} else {
|
||||
console.log("기타 팝업 닫힘 감지됨");
|
||||
popupRef.current = null;
|
||||
}
|
||||
}
|
||||
@@ -73,15 +61,6 @@ const Map = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("isFeatured ", isFeatured);
|
||||
|
||||
if (isFeatured) {
|
||||
console.log("isFeatured ", isFeatured);
|
||||
onFeatureSelect(isFeatured);
|
||||
}
|
||||
}, [isFeatured]);
|
||||
|
||||
// 체크된 레이어 정보를 받는 핸들러
|
||||
const handleCheckedLayersChange = (
|
||||
checkedIds: string[],
|
||||
@@ -92,11 +71,12 @@ const Map = () => {
|
||||
};
|
||||
|
||||
// SearchModal, GisContent 등에서 호출
|
||||
const onFeatureFocus = (data: any) => {
|
||||
console.log(data);
|
||||
const onFeatureFocus = () => {
|
||||
console.log("onFeatureFocus");
|
||||
};
|
||||
|
||||
const onFeatureSelect = (data: any) => {
|
||||
|
||||
const map = gisMaps.current;
|
||||
if (!map) {
|
||||
console.warn("지도가 초기화되지 않았습니다.");
|
||||
@@ -114,7 +94,6 @@ const Map = () => {
|
||||
let foundFeature: any = null;
|
||||
let featureId = data.fid;
|
||||
const layerName = data.layerNm;
|
||||
console.log("선택된 레이어 이름:", data, layerName);
|
||||
if (layerName === "하수재이용시설") {
|
||||
// 재이용시설
|
||||
featureId = `${layerName}.${data.searchPk}`;
|
||||
@@ -134,7 +113,6 @@ const Map = () => {
|
||||
//수요처
|
||||
featureId = `${layerName}.${data.dmdInfoId.replaceAll("-", "")}`;
|
||||
} else {
|
||||
console.warn("해당 레이어에 대한 featureId 매핑 없음", layerName);
|
||||
featureId = `${layerName}.${data.fid || data.id || data.ftrIdn}`;
|
||||
}
|
||||
map
|
||||
@@ -164,7 +142,7 @@ const Map = () => {
|
||||
}
|
||||
select.selectFeature(foundFeature, true);
|
||||
} else {
|
||||
console.warn("OpenLayers Feature를 찾을 수 없습니다:", data);
|
||||
console.warn("OpenLayers Feature를 찾을 수 없습니다:");
|
||||
}
|
||||
const currentId = data?.fid || data?.id || data?.ftrIdn;
|
||||
const lastId = featureData?.fid || featureData?.id || featureData?.ftrIdn;
|
||||
@@ -174,6 +152,23 @@ const Map = () => {
|
||||
setIsSearchModalOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (isFeatured) {
|
||||
onFeatureSelect(isFeatured);
|
||||
}
|
||||
}, [isFeatured]);
|
||||
|
||||
useEffect(() => {
|
||||
const bc = new BroadcastChannel("mapDetailPopUp");
|
||||
bc.onmessage = (event) => {
|
||||
if (event.data.type === "SELECT_FEATURE") {
|
||||
onFeatureSelect(event.data.feature);
|
||||
closePopup();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getSelectedLayers = (isFeature: any) => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) {
|
||||
@@ -214,7 +209,6 @@ const Map = () => {
|
||||
|
||||
const isFeature = props?.featureData;
|
||||
|
||||
console.log('isFeature 1', isFeature)
|
||||
if( typeof isFeature.getProperties === "function" ){
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// "use client";
|
||||
|
||||
// import Gis from "@common/business_components/ui/map/Gis";
|
||||
// import { useEffect, useRef, useState, useCallback } from "react";
|
||||
// import { mapService } from "../service";
|
||||
// import NewWindowPopup from "../components/newWindowPopup";
|
||||
// import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
|
||||
// const Map = () => {
|
||||
// const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
|
||||
// const gisMaps = useRef<any | null>(null);
|
||||
// const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
||||
// const [isSearchModalOpen, setIsSearchModalOpen] = useState(true); // SearchModal 열림 상태
|
||||
// const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
|
||||
// const [checkedLayers, setCheckedLayers] = useState<string[]>([]);
|
||||
// const [checkedLayersData, setCheckedLayersData] = useState<any[]>([]);
|
||||
|
||||
// const fetchData = async () => {
|
||||
// try {
|
||||
// const response = await mapService.getLyrStngs();
|
||||
// setTreeData(response); // API 응답 저장
|
||||
// } catch (error) {
|
||||
// console.error("데이터 불러오기 실패:", error);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // 팝업 닫기 핸들러를 useCallback으로 메모이제이션
|
||||
// const handlePopupClose = useCallback(() => {
|
||||
// setIsOpen(false);
|
||||
// setFeatureData(null);
|
||||
// }, []);
|
||||
|
||||
// // 컴포넌트 마운트 시 1회 실행
|
||||
// useEffect(() => {
|
||||
// fetchData();
|
||||
// }, []);
|
||||
|
||||
// // 체크된 레이어 정보를 받는 핸들러
|
||||
// const handleCheckedLayersChange = (checkedIds: string[], checkedNodesData: any[]) => {
|
||||
// setCheckedLayers(checkedIds);
|
||||
// setCheckedLayersData(checkedNodesData);
|
||||
// };
|
||||
|
||||
// // SearchModal, GisContent 등에서 호출
|
||||
// const onFeatureSelect = (data: any) => {
|
||||
// const map = gisMaps.current;
|
||||
// if (!map) {
|
||||
// console.warn("지도가 초기화되지 않았습니다.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // 항상 같은 select 객체 사용
|
||||
// const select = GisApp.Module.select as any;
|
||||
// if (!select) return;
|
||||
|
||||
// // 1. 기존 선택 초기화
|
||||
// select.resetSelectedFeature();
|
||||
|
||||
// // 2. data가 OpenLayers Feature 인스턴스인지 확인
|
||||
// let foundFeature: any = null;
|
||||
// if (
|
||||
// data &&
|
||||
// typeof data.getGeometry === "function" &&
|
||||
// typeof data.setStyle === "function"
|
||||
// ) {
|
||||
// // 이미 Feature 인스턴스라면 그대로 사용
|
||||
// foundFeature = data;
|
||||
// } else {
|
||||
// // 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용
|
||||
// const featureId = data.fid || data.ftrIdn || data.id;
|
||||
// map
|
||||
// .getLayers()
|
||||
// .getArray()
|
||||
// .forEach((layer: any) => {
|
||||
// const source = layer.getSource?.();
|
||||
// if (source?.getFeatureById) {
|
||||
// const feature = source.getFeatureById(String(featureId).trim());
|
||||
// if (feature) foundFeature = feature;
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// // 3. 피처 선택
|
||||
// if (foundFeature) {
|
||||
// const geometry = foundFeature.getGeometry();
|
||||
// if (geometry) {
|
||||
// const extent = geometry.getExtent();
|
||||
|
||||
// map.getView().fit(extent, {
|
||||
// duration: 300,
|
||||
// padding: [50, 50, 50, 50],
|
||||
// maxZoom: 16,
|
||||
// constrainResolution: true,
|
||||
// nearest: true,
|
||||
// });
|
||||
// }
|
||||
// select.selectFeature(foundFeature, true);
|
||||
// } else {
|
||||
// console.warn("OpenLayers Feature를 찾을 수 없습니다:", data);
|
||||
// }
|
||||
|
||||
// setFeatureData(data);
|
||||
// setIsOpen(true);
|
||||
// setIsSearchModalOpen(false);
|
||||
// };
|
||||
|
||||
// // MapSearchBar 래퍼 컴포넌트
|
||||
// // const CustomMapSearchBar = (props: any) => {
|
||||
// // return (
|
||||
// // <MapSearchBar
|
||||
// // {...props}
|
||||
// // searchType="공급처 후보지 조회"
|
||||
// // showApplyButton={false}
|
||||
// // />
|
||||
// // );
|
||||
// // };
|
||||
|
||||
// return (
|
||||
// <div className="bg-container">
|
||||
// <Gis
|
||||
// treeData={treeData}
|
||||
// isSearch={false}
|
||||
// gisMaps={gisMaps}
|
||||
// isMiniMap={false}
|
||||
// searchView={undefined}
|
||||
// detailView={(props: any) =>
|
||||
// // <NewWindowPopup
|
||||
// // onClose={handlePopupClose} // 메모이제이션된 함수 사용
|
||||
// // checkedLayers={checkedLayers}
|
||||
// // checkedLayersData={checkedLayersData}
|
||||
// // {...props}
|
||||
// // />
|
||||
// {
|
||||
|
||||
// console.log('p' ,props)
|
||||
// }
|
||||
// }
|
||||
// onFeatureSelect={onFeatureSelect}
|
||||
// setIsOpen={setIsOpen}
|
||||
// setFeatureData={setFeatureData}
|
||||
// featureData={featureData}
|
||||
// setIsSearchModalOpen={setIsSearchModalOpen}
|
||||
// isOpen={isOpen}
|
||||
// isSearchModalOpen={isSearchModalOpen}
|
||||
// onCheckedLayersChange={handleCheckedLayersChange}
|
||||
// imgVisble={true}
|
||||
// searchBar={undefined}
|
||||
// />
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export default Map;
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect} from "react";
|
||||
import React from "react";
|
||||
import { Label } from "@common/components/ui/label";
|
||||
|
||||
export const DRGT_LAYER_TYPES: string[] = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getCenter } from "ol/extent";
|
||||
import { boundingExtent } from "ol/extent";
|
||||
import Feature from "ol/Feature";
|
||||
import Geometry from "ol/geom/Geometry";
|
||||
import { boundingExtent, getCenter } from 'ol/extent';
|
||||
import Feature from 'ol/Feature';
|
||||
import Geometry from 'ol/geom/Geometry';
|
||||
|
||||
export function medianCoordinate2D(feature: Feature<Geometry>) {
|
||||
// Feature를 입력 받아 중심 좌표를 계산하는 함수
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Gis from "@common/business_components/ui/map/Gis";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { mapService } from "../../(with-layout)/map/service";
|
||||
import { mapService } from "../../(map-layout)/service";
|
||||
import { DrgtLegend, DRGT_LAYER_TYPES, EXPRS_ORDERED_LAYERS } from "./drgtLegend";
|
||||
import { SearchBar } from "./searchBar";
|
||||
|
||||
@@ -17,10 +17,7 @@ export default function MapDrgtPopUp() {
|
||||
|
||||
const gisMaps = useRef<any | null>(null);
|
||||
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
|
||||
const [checkedLayers, setCheckedLayers] = useState<string[]>([]);
|
||||
const [checkedLayersData, setCheckedLayersData] = useState<any[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
||||
const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
|
||||
const [showDrgtLegend, setShowDrgtLegend] = useState(false); // DrgtLegend 표시 여부
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SelectBox } from '@common/business_components/ui'
|
||||
import { Label, Button } from '@common/components';
|
||||
import { type SelectDataType } from "@common/business_components/ui";
|
||||
@@ -45,17 +45,21 @@ export function SearchBar({ gisMaps, ctpvCode, sggCode }: CtpvSggPrcsFcltSelectB
|
||||
|
||||
async function fetchCtpvData() {
|
||||
try {
|
||||
const ctpvData = await fetch(`/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo%3Atb_ms_ctpv&outputFormat=application%2Fjson&cql_filter=mctpv_cd=%27${ctpvCd.trim()}%27`)
|
||||
const ctpvData = await fetch(`/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo%3Atb_ms_ctpv&outputFormat=application%2Fjson&cql_filter=mctpv_cd=%27${ctpvCd.trim()}%27&srsName=EPSG:5179`)
|
||||
.then(res => res.json());
|
||||
console.log('시도 데이터', ctpvData.features[0]);
|
||||
|
||||
const format = new GeoJSON();
|
||||
const feature: Feature<Geometry> = format.readFeature(ctpvData.features[0], {
|
||||
dataProjection: "EPSG:3857", // GeoJSON 좌표계
|
||||
dataProjection: "EPSG:5179", // GeoJSON 좌표계
|
||||
featureProjection: GisApp.TO_PROJ // 지도 좌표계
|
||||
});
|
||||
|
||||
console.log('feature', feature);
|
||||
|
||||
const extent = medianCoordinate2D(feature);
|
||||
console.log('GisApp.TO_PROJ', GisApp.TO_PROJ);
|
||||
console.log('extent', extent)
|
||||
|
||||
handleFeatureFocus(extent);
|
||||
}
|
||||
@@ -66,14 +70,14 @@ export function SearchBar({ gisMaps, ctpvCode, sggCode }: CtpvSggPrcsFcltSelectB
|
||||
|
||||
async function fetchSggData() {
|
||||
try {
|
||||
const sggData = await fetch(`/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo%3Atb_ms_sgg&outputFormat=application%2Fjson&cql_filter=sgg_cd=%27${sggCd}%27`)
|
||||
const sggData = await fetch(`/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo%3Atb_ms_sgg&outputFormat=application%2Fjson&cql_filter=sgg_cd=%27${sggCd}%27&srsName=EPSG:5179`)
|
||||
.then(res => res.json());
|
||||
console.log('시군구 데이터', sggData.features[0]);
|
||||
|
||||
|
||||
const format = new GeoJSON();
|
||||
const feature: Feature<Geometry> = format.readFeature(sggData.features[0], {
|
||||
dataProjection: "EPSG:3857", // GeoJSON 좌표계
|
||||
dataProjection: "EPSG:5179", // GeoJSON 좌표계
|
||||
featureProjection: GisApp.TO_PROJ // 지도 좌표계
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type SearchType = {
|
||||
bscFctNm: string;
|
||||
bscFctCd: string;
|
||||
};
|
||||
|
||||
type thisProps = {
|
||||
label: string | undefined;
|
||||
initValue: string | undefined;
|
||||
setValue: (searchNm?: string) => void;
|
||||
setRtnData: (rtnData: any) => void;
|
||||
searchList?: SearchType[];
|
||||
};
|
||||
|
||||
export default function SearchInputText({
|
||||
label = undefined,
|
||||
initValue = undefined,
|
||||
setValue = () => {},
|
||||
setRtnData = () => {},
|
||||
searchList = [],
|
||||
}: thisProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [textValue, setTextValue] = useState(initValue || "");
|
||||
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setTextValue(value);
|
||||
setValue(value);
|
||||
setHighlightIndex(-1);
|
||||
setIsOpen(value !== "");
|
||||
},
|
||||
[setValue]
|
||||
);
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(searchNm: string) => {
|
||||
setValue(searchNm);
|
||||
setTextValue(searchNm);
|
||||
setIsOpen(false);
|
||||
setRtnData(searchList?.find((item) => item.bscFctNm === searchNm) || {});
|
||||
},
|
||||
[searchList, setRtnData, setValue]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen || searchList.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev + 1) % searchList.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) =>
|
||||
prev <= 0 ? searchList.length - 1 : prev - 1
|
||||
);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (searchList.length === 1 && searchList[0]) {
|
||||
handleSuggestionClick(searchList[0].bscFctNm);
|
||||
} else if (highlightIndex >= 0 && highlightIndex < searchList.length && searchList[highlightIndex]) {
|
||||
handleSuggestionClick(searchList[highlightIndex].bscFctNm);
|
||||
}
|
||||
}
|
||||
},
|
||||
[highlightIndex, isOpen, searchList, handleSuggestionClick]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={textValue || ""}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full h-[3.2rem] !text-xl pl-3 text-gray-600 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={`${label}을(를) 입력하세요`}
|
||||
onClick={() => {
|
||||
setTextValue("");
|
||||
setValue("");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 z-50 mb-2 max-h-72 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
<div className="max-h-72">
|
||||
{searchList.length > 0 ? (
|
||||
searchList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-full cursor-pointer overflow-auto px-5 py-3 text-left !text-xl ${
|
||||
highlightIndex === index
|
||||
? "bg-blue-100"
|
||||
: "hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => handleSuggestionClick(item.bscFctNm)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleSuggestionClick(item.bscFctNm);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="float-left">{item.bscFctNm}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="!text-xl w-full px-5 py-3 hover:bg-gray-100 cursor-pointer overflow-auto">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import Gis from "@common/business_components/ui/map/Gis";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { mapService } from "./service";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { SearchBar } from "./searchBar/searchBar";
|
||||
import SelectedFcltList from "./selectedFcltList";
|
||||
|
||||
export type SubsInfo = {
|
||||
ruseUtztnFcltNm: string; // 재이용시설명
|
||||
ruseUtztnFcltCd: string; // 재이용시설 코드
|
||||
}
|
||||
|
||||
const Map = () => {
|
||||
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
|
||||
const gisMaps = useRef<any | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(true); // SearchModal 열림 상태
|
||||
const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
|
||||
const [selectDataPk, setSelectDataPk] = useState<Record<string, any>>({});
|
||||
const [selectedFclts, setSelectedFclts] = useState<any[]>([]);
|
||||
|
||||
//적용 버튼 클릭 이벤트
|
||||
const onApply = () => {
|
||||
if (selectedFclts.length > 1) {
|
||||
alert("재이용시설 공급처는 1곳만 선택할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const subsInfoList: SubsInfo[] = selectedFclts.map((fclt) => ({
|
||||
ruseUtztnFcltNm: fclt.ruseUtztnFcltNm,
|
||||
ruseUtztnFcltCd: fclt.pbnstRuseFcltId,
|
||||
}));
|
||||
|
||||
if (!window.opener || typeof window.opener.postMessage !== "function") {
|
||||
alert("부모 창을 찾을 수 없습니다. 팝업이 'noopener'로 열렸거나, 보안 정책으로 차단되었을 수 있어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: "SET_SUBS_INFO",
|
||||
payload: subsInfoList[0], // 또는 필요한 데이터
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
window.close();
|
||||
};
|
||||
|
||||
//트리 레이어 데이터 조회
|
||||
const fetchTreeData = async () => {
|
||||
try {
|
||||
const response = await mapService.getLyrStngs();
|
||||
setTreeData(response); // API 응답 저장
|
||||
} catch (error) {
|
||||
console.error("데이터 불러오기 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
//하수처리시설코드로 재이용시설 목록 조회
|
||||
const reuseData = async (ruseFcltPk: any) => {
|
||||
try {
|
||||
const response = await mapService.getPbnstRuseFclts(ruseFcltPk);
|
||||
const data = response.data || [];
|
||||
setSelectedFclts((prev) => {
|
||||
const newItems = data.filter(
|
||||
(item: any) =>
|
||||
item &&
|
||||
Object.keys(item).length > 0 &&
|
||||
!prev.some((f) => f.pbnstRuseFcltId === item.pbnstRuseFcltId)
|
||||
);
|
||||
console.log(newItems);
|
||||
return [...prev, ...newItems];
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("데이터 불러오기 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 1회 실행
|
||||
useEffect(() => {
|
||||
fetchTreeData();
|
||||
}, []);
|
||||
|
||||
// 팝업 Map.tsx useEffect 안
|
||||
useEffect(() => {
|
||||
const origin = window.location.origin;
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: "REQUEST_INIT_SUBS_INFO" }, origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const origin = window.location.origin;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
// 동일 출처만 허용 (개발 중엔 임시로 주석처리하고 *로 테스트해도 됨)
|
||||
if (event.origin !== origin) return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
if (data.type === "INIT_SUBS_INFO") {
|
||||
// payload: [{ ruseUtztnFcltNm, ruseUtztnFcltCd }, ...]
|
||||
const list = Array.isArray(data.payload) ? data.payload : [];
|
||||
|
||||
// selectedFclts 형태에 맞게 변환해서 반영
|
||||
// (지금 onApply에서 pbnstRuseFcltId를 쓰므로 매핑 맞춰줍니다)
|
||||
setSelectedFclts(list.map((x) => ({
|
||||
ruseUtztnFcltNm: x.ruseUtztnFcltNm ?? "",
|
||||
pbnstRuseFcltId: x.ruseUtztnFcltCd ?? "", // <- 기존 코드가 기대하는 키
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, []);
|
||||
|
||||
// 레이어 생성 후 초기화 처리
|
||||
const onCheckedLayersChange = () => {
|
||||
//수요처 ID
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const dmdInfoId = urlParams.get("dmdInfoId");
|
||||
|
||||
//수요처 위치로 화면 이동
|
||||
const maps = gisMaps.current;
|
||||
const layer = GisApp.Module.layer as any;
|
||||
const layerNm = "tb_wr_ruse_fclt_dmd_co";
|
||||
const featureId = `${layerNm}.${dmdInfoId}`;
|
||||
let foundFeature: any = null;
|
||||
|
||||
maps
|
||||
.getLayers()
|
||||
.getArray()
|
||||
.forEach((layer: any) => {
|
||||
if (layer.get("lyrTyNm") == layerNm) {
|
||||
const source = layer.getSource?.();
|
||||
if (source?.getFeatureById) {
|
||||
const feature = source.getFeatureById(String(featureId).trim());
|
||||
if (feature) {
|
||||
foundFeature = feature;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundFeature) {
|
||||
const geometry = foundFeature.getGeometry();
|
||||
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
|
||||
if (geometry.getType() === "Point") {
|
||||
maps.getView().setCenter(geometry.getCoordinates());
|
||||
maps.getView().setZoom(11);
|
||||
} else {
|
||||
maps.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [30, 30, 30, 30],
|
||||
maxZoom: 16,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
layer.highLightFeature(
|
||||
[foundFeature],
|
||||
{
|
||||
id: GisApp.hlLayerId,
|
||||
zIndex: 10,
|
||||
opacity: 1,
|
||||
selectable: true,
|
||||
},
|
||||
{ width: 30 }
|
||||
);
|
||||
}
|
||||
|
||||
const formatNumber = (val: any) => {
|
||||
const num = Number(val);
|
||||
return isNaN(num) ? "-" : num.toLocaleString();
|
||||
};
|
||||
|
||||
// 재이용시설 오버레이
|
||||
maps
|
||||
.getLayers()
|
||||
.getArray()
|
||||
.forEach((layer: any) => {
|
||||
if (layer.get("lyrTyNm") === "tb_wr_bdst_ruse_fclt") {
|
||||
const source = layer.getSource?.();
|
||||
if (source?.getFeatures) {
|
||||
const features = source.getFeatures();
|
||||
|
||||
features.forEach((feature: any) => {
|
||||
// 필요한 고유 ID 또는 속성 가져오기 (예: ftrIdn 또는 feature.getId())
|
||||
const featureId =
|
||||
feature.getId() || feature.get("ftrIdn") || Math.random();
|
||||
|
||||
// HTML 또는 React 오버레이 컴포넌트 구성
|
||||
const overlayContent = `
|
||||
<div class="rounded-md border border-gray-300 bg-white shadow-md text-sm w-[120px]">
|
||||
<!-- 헤더 -->
|
||||
<div class="bg-cyan-700 text-white px-3 py-2 text-[11px] font-semibold text-center rounded-t-md">
|
||||
${feature.get("swtrmt_fclt_nm") || "-"}
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<table class="w-full text-sm text-gray-800 border-t border-gray-200">
|
||||
<tbody>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="bg-gray-100 text-gray-600 font-semibold text-[10px] text-left">용량(m³/일)</th>
|
||||
<td class="text-right text-[10px] font-bold">${formatNumber(feature.get("fclt_cpct"))}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
feature.set("html", overlayContent);
|
||||
feature.set("overLayId", `overLay.${featureId}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 검색해서 재이용시설 선택하면 해당 시설로 화면 이동 처리
|
||||
useEffect(() => {
|
||||
if (!selectDataPk || Object.keys(selectDataPk).length === 0) return;
|
||||
const maps = gisMaps.current;
|
||||
let foundFeature: any = null;
|
||||
const layerNm = "tb_wr_bdst_ruse_fclt";
|
||||
const featureId = `${layerNm}.${selectDataPk.bscFctCd}`;
|
||||
maps
|
||||
.getLayers()
|
||||
.getArray()
|
||||
.forEach((layer: any) => {
|
||||
if (layer.get("lyrTyNm") == layerNm) {
|
||||
const source = layer.getSource?.();
|
||||
if (source?.getFeatureById) {
|
||||
const feature = source.getFeatureById(String(featureId).trim());
|
||||
if (feature) {
|
||||
foundFeature = feature;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundFeature) {
|
||||
const geometry = foundFeature.getGeometry();
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
|
||||
maps.getView().fit(extent, {
|
||||
duration: 300,
|
||||
padding: [50, 50, 50, 50],
|
||||
maxZoom: 16,
|
||||
constrainResolution: true,
|
||||
nearest: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("OpenLayers Feature를 찾을 수 없습니다:", selectDataPk);
|
||||
}
|
||||
}, [selectDataPk]);
|
||||
|
||||
// 재이용시설 클릭 시 선택 시설목록에 추가
|
||||
const onFeatureSelect = (data: any) => {
|
||||
// 처리시설코드
|
||||
const swtrmtFcltCd = data.get("swtrmt_fclt_cd");
|
||||
reuseData(swtrmtFcltCd);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-container">
|
||||
<Gis
|
||||
height='h-screen'
|
||||
treeData={treeData}
|
||||
isSearch={false}
|
||||
gisMaps={gisMaps}
|
||||
isMiniMap={false}
|
||||
searchView={undefined}
|
||||
detailView={undefined}
|
||||
onFeatureSelect={onFeatureSelect}
|
||||
setIsOpen={setIsOpen}
|
||||
setFeatureData={setFeatureData}
|
||||
featureData={featureData}
|
||||
setIsSearchModalOpen={setIsSearchModalOpen}
|
||||
isOpen={isOpen}
|
||||
isSearchModalOpen={isSearchModalOpen}
|
||||
onCheckedLayersChange={onCheckedLayersChange}
|
||||
imgVisble={false}
|
||||
searchBar={
|
||||
<SearchBar
|
||||
setHasuPk={(hasuPk: any) => {
|
||||
if (hasuPk) setSelectDataPk(hasuPk);
|
||||
}}
|
||||
onApply={onApply}
|
||||
/>
|
||||
}
|
||||
waterDepth={undefined}
|
||||
/>
|
||||
</div>
|
||||
<SelectedFcltList
|
||||
data={selectedFclts}
|
||||
onRemove={(updatedList) => setSelectedFclts(updatedList)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Map;
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import { useWrStore } from "@/app/store";
|
||||
import {
|
||||
SelectBox,
|
||||
type SelectDataType,
|
||||
} from "@common/business_components/ui";
|
||||
import { Button, Label } from "@common/components/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import SearchInputText from "../SearchInputText/SearchInputText";
|
||||
|
||||
export function SearchBar({
|
||||
setHasuPk,
|
||||
onApply,
|
||||
}: {
|
||||
setHasuPk: (hasuPk: any) => void;
|
||||
onApply: () => void;
|
||||
}) {
|
||||
const { map, code, codeInit, findRuseFclt } = useWrStore(
|
||||
(state) => state
|
||||
);
|
||||
const [sidoValue, setSidoValue] = useState<string>("");
|
||||
const [sigunValue, setSigunValue] = useState<string>("");
|
||||
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "전체", val: "0" },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
//codeInit();
|
||||
}, [codeInit]);
|
||||
|
||||
const initializeSigunData = useCallback(
|
||||
(sidoCd: string) => {
|
||||
if (sidoCd === "0") {
|
||||
setSigunValue("0");
|
||||
setFilteredSigunData([{ text: "전체", val: "0" }]);
|
||||
} else {
|
||||
const newFilteredData = [
|
||||
{ text: "전체", val: "0" },
|
||||
...code.sggCd.filter(
|
||||
(item: SelectDataType) => item.val.substring(0, 2) === sidoCd
|
||||
),
|
||||
];
|
||||
setSigunValue("0");
|
||||
setFilteredSigunData(newFilteredData);
|
||||
}
|
||||
},
|
||||
[code.sggCd]
|
||||
);
|
||||
|
||||
const onSelectValue = useCallback(
|
||||
(val: string, type: string) => {
|
||||
switch (type) {
|
||||
case "ctpvCd":
|
||||
initializeSigunData(val);
|
||||
setSidoValue(val);
|
||||
break;
|
||||
case "sggCd":
|
||||
setSigunValue(val);
|
||||
}
|
||||
},
|
||||
[initializeSigunData]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full px-4 py-3">
|
||||
<div className="flex items-center w-full max-w-screen-lg rounded-xl bg-white shadow-md px-6 py-4 space-x-3">
|
||||
<SelectBox
|
||||
labelClass="w-[40px]"
|
||||
className="w-[200px]"
|
||||
label="시도"
|
||||
selectClass="rounded-lg bg-white"
|
||||
selectArray={code.cityCd || []}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
value={sidoValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[65px]"
|
||||
className="w-[200px]"
|
||||
label="시군구"
|
||||
selectClass="rounded-lg bg-white"
|
||||
selectArray={filteredSigunData || []}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "sggCd")}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<Label className="text-gray-600">하수재이용시설</Label>
|
||||
<SearchInputText
|
||||
label="시설명"
|
||||
initValue={undefined}
|
||||
setValue={(searchNm?: string) => {
|
||||
let sggOrCity = "";
|
||||
if (searchNm) {
|
||||
if (sigunValue === "0" || sigunValue === "") {
|
||||
sggOrCity = sidoValue;
|
||||
} else {
|
||||
sggOrCity = sigunValue;
|
||||
}
|
||||
findRuseFclt(sggOrCity, searchNm);
|
||||
}
|
||||
}}
|
||||
setRtnData={(rtnData: any) => {
|
||||
setHasuPk(rtnData);
|
||||
}}
|
||||
searchList={map.ruseFcltList || []}
|
||||
/>
|
||||
<Button
|
||||
color="sky"
|
||||
className="ml-2 text-white"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onApply();
|
||||
}}
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
|
||||
type FcltItem = {
|
||||
pbnstRuseFcltId: string;
|
||||
ruseUtztnFcltNm: string;
|
||||
splyExtraCpct?: number;
|
||||
ruseCpct?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export default function SelectedFcltList({
|
||||
data = [],
|
||||
onRemove,
|
||||
}: {
|
||||
data: FcltItem[];
|
||||
onRemove: (updatedList: FcltItem[]) => void;
|
||||
}) {
|
||||
const handleDelete = (code: string) => {
|
||||
const updatedList = data.filter((item) => item.pbnstRuseFcltId !== code);
|
||||
onRemove(updatedList);
|
||||
};
|
||||
|
||||
const TableContent = (
|
||||
<>
|
||||
<thead className="bg-gray-100 text-gray-700 text-[13px]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left border-b">시설명</th>
|
||||
<th className="px-3 py-2 text-right border-b">
|
||||
하수처리량
|
||||
<br />
|
||||
(㎥/일)
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right border-b">
|
||||
재이용량
|
||||
<br />
|
||||
(㎥/일)
|
||||
</th>
|
||||
<th className="px-3 py-2 border-b">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr key={item.pbnstRuseFcltId} className="border-b last:border-none">
|
||||
<td className="px-3 py-2 text-[12px]">{item.ruseUtztnFcltNm}</td>
|
||||
<td className="px-3 py-2 text-right text-[12px]">
|
||||
{Number(item.splyExtraCpct || 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-[12px]">
|
||||
{Number(item.ruseCpct || 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
onClick={() => handleDelete(item.pbnstRuseFcltId)}
|
||||
className="text-red-500 hover:text-red-700 text-sm font-bold"
|
||||
aria-label="삭제"
|
||||
title="삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</>
|
||||
);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed right-40 top-[8rem] w-[40rem] max-h-[130rem] overflow-auto bg-white shadow-lg rounded p-4 border border-gray-300 z-50">
|
||||
<h3 className="bg-cyan-700 text-white text-xl font-bold mb-2 p-2">
|
||||
선택된 시설
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-gray-800 border border-gray-200">
|
||||
{TableContent}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './map'
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
/**
|
||||
* 관망도 API
|
||||
*/
|
||||
import { DEV_URL_WR, DEV_URL_SN } from "@/components/lib/config";
|
||||
|
||||
export type LyrStngProps = {
|
||||
lyr_sn: string
|
||||
lyr_nm: string
|
||||
upper_lyr_sn: string
|
||||
drawOrdr: string
|
||||
flterSql: string
|
||||
gobjTy: string
|
||||
lyrTpNm: string
|
||||
useYn: string
|
||||
minViewSize: string
|
||||
iconNm: string
|
||||
lblAtrbNm: string
|
||||
fontNm: string
|
||||
fontIntnColor: string
|
||||
fontExtnColor: string
|
||||
fontExtnWdth: string
|
||||
lnWdth: string
|
||||
lnColor: string
|
||||
polgnColor: string
|
||||
initEprssYn: string
|
||||
clickPsblYn: string
|
||||
}
|
||||
|
||||
export const mapService = {
|
||||
//레이어 설정 조회
|
||||
getLyrStngs: async () => {
|
||||
const response = await fetch(`${DEV_URL_SN}/v1/map/lyrStngs/DM_NETWORK`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
getPbnstRuseFclts: async (ruseFcltPk:any) => {
|
||||
const response = await fetch(`${DEV_URL_WR}/reuseFc/pubFc/${ruseFcltPk}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -389,7 +389,7 @@ export default function CoMatRvw() {
|
||||
className="w-full pr-16 border py-2 text-right rounded-md"
|
||||
placeholder=""
|
||||
value={
|
||||
field.value != -1
|
||||
field.value != -1 ||field.value != undefined
|
||||
? `${field.value}`
|
||||
: "불검출"
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ export default function DrgtSttn() {
|
||||
popupRef.current = window.open(`/mapDrgtPopUp?${params.toString()}`, "가뭄 지도 보기 팝업", popupOptions);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentsDiv>
|
||||
|
||||
@@ -4,6 +4,8 @@ import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import koBase from "../public/locales/ko/message.json";
|
||||
import { StoreProvider } from "./store";
|
||||
import { LoginApi } from "@/src/entities/auth";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -20,6 +22,14 @@ export default function RootLayout({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 렌더 중이 아니라 클라이언트에서만 실행 + 에러 캐치
|
||||
void LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" })
|
||||
.catch((e) => {
|
||||
console.error("Auto login failed:", e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// SearchInputText.tsx
|
||||
"use client";
|
||||
import { Input } from "@common/components/ui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SearchInputTextProps {
|
||||
initValue?: string;
|
||||
setValue: (val: string) => void;
|
||||
setRtnData: (val: { fnm: string; fid: string }) => void;
|
||||
searchList: { fnm: string; fid: string }[];
|
||||
onEnter?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SearchInputText({
|
||||
initValue,
|
||||
setValue,
|
||||
setRtnData,
|
||||
searchList,
|
||||
onEnter,
|
||||
className,
|
||||
}: SearchInputTextProps) {
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [text, setText] = useState(initValue ?? "");
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1);
|
||||
const wrapperRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setText(initValue ?? "");
|
||||
}, [initValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchList.length > 0) {
|
||||
setShowResults(true);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
}
|
||||
setHighlightIndex(-1);
|
||||
}, [searchList]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!(wrapperRef.current as any).contains(event.target)
|
||||
) {
|
||||
setShowResults(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [wrapperRef]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.target.value);
|
||||
setValue(e.target.value);
|
||||
setHighlightIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (item: { fnm: string; fid: string }) => {
|
||||
setText(item.fnm);
|
||||
setValue(item.fnm);
|
||||
setRtnData(item);
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
if (highlightIndex >= 0 && highlightIndex < searchList.length) {
|
||||
const selected = searchList[highlightIndex];
|
||||
if (selected) handleSelect(selected);
|
||||
} else if (onEnter) {
|
||||
onEnter();
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) =>
|
||||
prev < searchList.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<Input
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="시설명을 입력하세요"
|
||||
className={
|
||||
className + " rounded-lg border px-2 py-1 text-sm h-[29.5px]"
|
||||
}
|
||||
/>
|
||||
{showResults && (
|
||||
<div className="absolute z-10 mt-1 max-h-200 w-full overflow-auto rounded border bg-white shadow">
|
||||
{searchList.length > 0 ? (
|
||||
searchList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`cursor-pointer px-3 py-1 hover:bg-gray-100 ${
|
||||
highlightIndex === index ? "bg-blue-100" : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.fnm}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client";
|
||||
import { SelectBox, type SelectDataType } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const geoserverUrl =
|
||||
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&outputFormat=application/json";
|
||||
|
||||
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
|
||||
tb_il_swage_prcs_fclt: geoserverUrl, //하수처리시설
|
||||
tb_wr_bdst_ruse_fclt:
|
||||
"/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000", //재이용시설 post
|
||||
tb_wr_rnwtr_utztn_fclt: "/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}", //빗물이용시설 get
|
||||
tb_wr_rswtr_fclt: "/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}", //중수도시설 get
|
||||
tb_wr_htdfl_fclt: "/reuse-service/drain/facilityList/1/1000/{sggCd}", //온배수시설 get
|
||||
tb_wr_ruse_fclt_dmd_co: "/reuse-service/reuseFc/request/1/1000/{sggCd}", //수요처 get
|
||||
tb_wr_tmpr_dmd_co:
|
||||
"/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}", //잠재수요처 get
|
||||
};
|
||||
|
||||
interface SearchBarProps {
|
||||
onFeatureSelect: (feature: any) => void;
|
||||
gisMaps: React.RefObject<any>;
|
||||
facilityTypes: { text: string; val: string }[];
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
onFeatureSelect,
|
||||
gisMaps,
|
||||
facilityTypes,
|
||||
}: SearchBarProps) {
|
||||
const [sidoValue, setSidoValue] = useState("0");
|
||||
const [sigunValue, setSigunValue] = useState("0");
|
||||
const [sidoData, setSidoData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
const [sigunData, setSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
|
||||
const [facilityData, setFacilityData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
const [facilityValue, setFacilityValue] = useState("0");
|
||||
|
||||
const [facilityType, setFacilityType] = useState(
|
||||
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0"
|
||||
);
|
||||
|
||||
//시도, 시군구 조회
|
||||
const fetchSido = async () => {
|
||||
const url = `/cms-service/api/v1/code/cache/ADSTDSTRCD/Y`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
const sidoList = json
|
||||
.filter(
|
||||
(item: any) =>
|
||||
typeof item.lwpoCd === "string" && item.lwpoCd.length === 2
|
||||
)
|
||||
.map((feature: any) => ({
|
||||
val: feature.lwpoCd,
|
||||
text: feature.lwpoCdNm,
|
||||
}));
|
||||
setSidoData([{ text: "선택", val: "0" }, ...sidoList]);
|
||||
|
||||
const sigunList = json
|
||||
.filter(
|
||||
(item: any) =>
|
||||
typeof item.lwpoCd === "string" && item.lwpoCd.length !== 2
|
||||
)
|
||||
.map((feature: any) => ({
|
||||
val: feature.lwpoCd,
|
||||
text: feature.lwpoCdNm,
|
||||
}));
|
||||
setSigunData(sigunList);
|
||||
};
|
||||
|
||||
//시설명 콤보박스 조회
|
||||
const fetchFacilitiesBySggNm = async (
|
||||
sggCd: string,
|
||||
sggNm: string,
|
||||
sidoNm: string,
|
||||
currentFacilityType?: string
|
||||
) => {
|
||||
const type = currentFacilityType ?? facilityType;
|
||||
if (!type || type === "0") return;
|
||||
|
||||
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
|
||||
if (!baseUrlTemplate) return;
|
||||
|
||||
const year = new Date().getFullYear().toString();
|
||||
let url = baseUrlTemplate
|
||||
.replace("{year}", year)
|
||||
.replace("{sggCd}", sggCd)
|
||||
.replace("{sidoNm}", encodeURIComponent(sidoNm))
|
||||
.replace("{sggNm}", encodeURIComponent(sggNm));
|
||||
|
||||
let list: SelectDataType[] = [];
|
||||
|
||||
if (
|
||||
type === "tb_il_swage_prcs_fclt" //하수처리시설
|
||||
) {
|
||||
const cqlFilter = `ctpv_nm='${encodeURIComponent(sidoNm)}' AND sgg_nm='${encodeURIComponent(sggNm)}'`;
|
||||
url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = json.features.map((f: any) => ({
|
||||
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
|
||||
val: f.id,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
|
||||
) {
|
||||
//재용에서 참조하는 하수처리시설 목록
|
||||
const paramsRes = await fetch("/reuse-service/reuseFc/pubFc");
|
||||
const paramsJson = await paramsRes.json();
|
||||
const params = paramsJson.data;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.ruseUtztnFcltNm ?? "(이름없음)",
|
||||
val: item.pbnstRuseFcltId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_ruse_fclt_dmd_co" //수요처
|
||||
) {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.dmdInfoId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_tmpr_dmd_co" //잠재수요처
|
||||
) {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.fctryMngSn,
|
||||
}));
|
||||
} else {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.bdstNm ?? "(이름없음)",
|
||||
val: item.bdstInfoSn,
|
||||
}));
|
||||
}
|
||||
|
||||
setFacilityData([{ text: "선택", val: "0" }, ...list]);
|
||||
setFacilityValue("0");
|
||||
};
|
||||
|
||||
//시도, 시군구 초기 데이터 조회
|
||||
useEffect(() => {
|
||||
fetchSido();
|
||||
}, []);
|
||||
|
||||
//시군구 데이터 초기화
|
||||
const initializeSigunData = useCallback(
|
||||
(sidoCd: string) => {
|
||||
if (sidoCd === "0") {
|
||||
setFilteredSigunData([{ text: "선택", val: "0" }]);
|
||||
setSigunValue("0");
|
||||
return;
|
||||
}
|
||||
const prefix = sidoCd.substring(0, 2);
|
||||
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
|
||||
setFilteredSigunData([{ text: "선택", val: "0" }, ...filtered]);
|
||||
setSigunValue("0");
|
||||
},
|
||||
[sigunData]
|
||||
);
|
||||
|
||||
//콤보박스 선택시 연관 콤보박스 처리
|
||||
const onSelectValue = useCallback(
|
||||
(val: string, type: string) => {
|
||||
if (type === "ctpvCd") {
|
||||
setSidoValue(val);
|
||||
initializeSigunData(val);
|
||||
setFacilityData([{ text: "선택", val: "0" }]);
|
||||
setFacilityValue("0");
|
||||
} else if (type === "sggCd") {
|
||||
setSigunValue(val);
|
||||
const sidoNm =
|
||||
sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm = sigunData.find((item) => item.val === val)?.text || "";
|
||||
const sggCd = sigunData.find((item) => item.val === val)?.val || "";
|
||||
if (sggNm && sidoNm)
|
||||
fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType);
|
||||
} else if (type === "facilityType") {
|
||||
setFacilityType(val);
|
||||
setFacilityValue("0");
|
||||
setFacilityData([{ text: "선택", val: "0" }]);
|
||||
const sidoNm =
|
||||
sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm =
|
||||
sigunData.find((item) => item.val === sigunValue)?.text || "";
|
||||
const sggCd =
|
||||
sigunData.find((item) => item.val === sigunValue)?.val || "";
|
||||
if (sggNm && sidoNm) fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, val);
|
||||
} else if (type === "fcltCd") {
|
||||
setFacilityValue(val);
|
||||
}
|
||||
},
|
||||
[initializeSigunData, sigunData, sigunValue, sidoData, sidoValue]
|
||||
);
|
||||
|
||||
//조회 버튼 클릭
|
||||
const handleSearch = () => {
|
||||
if (!gisMaps.current || facilityValue === "0") return;
|
||||
const layers = gisMaps.current.getLayers().getArray();
|
||||
for (const layer of layers) {
|
||||
const source = layer.getSource?.();
|
||||
const feature = source?.getFeatureById?.(facilityValue);
|
||||
if (feature) {
|
||||
onFeatureSelect(feature);
|
||||
return;
|
||||
}
|
||||
}
|
||||
alert("해당 처리시설을 지도에서 찾을 수 없습니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-4">
|
||||
<div className="card card-border flex">
|
||||
<div className="col-span-10 flex flex-row">
|
||||
{facilityTypes.length > 1 && (
|
||||
<SelectBox
|
||||
labelClass="w-[120px]"
|
||||
className="mr-10 w-[190px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시설물유형"
|
||||
selectArray={[{ text: "선택", val: "0" }, ...facilityTypes]}
|
||||
onSelectValue={(val) =>
|
||||
onSelectValue(val.toString(), "facilityType")
|
||||
}
|
||||
value={facilityType}
|
||||
/>
|
||||
)}
|
||||
<SelectBox
|
||||
labelClass="w-[40px]"
|
||||
className="mr-10 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시도"
|
||||
selectArray={sidoData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
value={sidoValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[65px]"
|
||||
className="mr-10 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시군구"
|
||||
selectArray={filteredSigunData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "sggCd")}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[60px]"
|
||||
className="mr-3 w-[180px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시설명"
|
||||
selectArray={facilityData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "fcltCd")}
|
||||
value={facilityValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Button
|
||||
color="primary"
|
||||
className="mx-2 mt-1 ml-5"
|
||||
size={"sm"}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client";
|
||||
import { SelectBox, type SelectDataType } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const geoserverUrl =
|
||||
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&outputFormat=application/json";
|
||||
|
||||
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
|
||||
tb_il_swage_prcs_fclt: geoserverUrl, //하수처리시설
|
||||
tb_wr_bdst_ruse_fclt:
|
||||
"/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000", //재이용시설 post
|
||||
tb_wr_rnwtr_utztn_fclt: "/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}", //빗물이용시설 get
|
||||
tb_wr_rswtr_fclt: "/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}", //중수도시설 get
|
||||
tb_wr_htdfl_fclt: "/reuse-service/drain/facilityList/1/1000/{sggCd}", //온배수시설 get
|
||||
tb_wr_ruse_fclt_dmd_co: "/reuse-service/reuseFc/request/1/1000/{sggCd}", //수요처 get
|
||||
tb_wr_tmpr_dmd_co:
|
||||
"/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}", //잠재수요처 get
|
||||
};
|
||||
|
||||
interface SearchBarProps {
|
||||
onFeatureSelect: (feature: any) => void;
|
||||
gisMaps: React.RefObject<any>;
|
||||
facilityTypes: { text: string; val: string }[];
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
onFeatureSelect,
|
||||
gisMaps,
|
||||
facilityTypes,
|
||||
}: SearchBarProps) {
|
||||
const [sidoValue, setSidoValue] = useState("0");
|
||||
const [sigunValue, setSigunValue] = useState("0");
|
||||
const [sidoData, setSidoData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
const [sigunData, setSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
|
||||
const [facilityData, setFacilityData] = useState<SelectDataType[]>([
|
||||
{ text: "선택", val: "0" },
|
||||
]);
|
||||
const [facilityValue, setFacilityValue] = useState("0");
|
||||
|
||||
const [facilityType, setFacilityType] = useState(
|
||||
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0"
|
||||
);
|
||||
|
||||
//시도, 시군구 조회
|
||||
const fetchSido = async () => {
|
||||
const url = `/cms-service/api/v1/code/cache/ADSTDSTRCD/Y`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
const sidoList = json
|
||||
.filter(
|
||||
(item: any) =>
|
||||
typeof item.lwpoCd === "string" && item.lwpoCd.length === 2
|
||||
)
|
||||
.map((feature: any) => ({
|
||||
val: feature.lwpoCd,
|
||||
text: feature.lwpoCdNm,
|
||||
}));
|
||||
setSidoData([{ text: "선택", val: "0" }, ...sidoList]);
|
||||
|
||||
const sigunList = json
|
||||
.filter(
|
||||
(item: any) =>
|
||||
typeof item.lwpoCd === "string" && item.lwpoCd.length !== 2
|
||||
)
|
||||
.map((feature: any) => ({
|
||||
val: feature.lwpoCd,
|
||||
text: feature.lwpoCdNm,
|
||||
}));
|
||||
setSigunData(sigunList);
|
||||
};
|
||||
|
||||
//시설명 콤보박스 조회
|
||||
const fetchFacilitiesBySggNm = async (
|
||||
sggCd: string,
|
||||
sggNm: string,
|
||||
sidoNm: string,
|
||||
currentFacilityType?: string
|
||||
) => {
|
||||
const type = currentFacilityType ?? facilityType;
|
||||
if (!type || type === "0") return;
|
||||
|
||||
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
|
||||
if (!baseUrlTemplate) return;
|
||||
|
||||
const year = new Date().getFullYear().toString();
|
||||
let url = baseUrlTemplate
|
||||
.replace("{year}", year)
|
||||
.replace("{sggCd}", sggCd)
|
||||
.replace("{sidoNm}", encodeURIComponent(sidoNm))
|
||||
.replace("{sggNm}", encodeURIComponent(sggNm));
|
||||
|
||||
let list: SelectDataType[] = [];
|
||||
|
||||
if (
|
||||
type === "tb_il_swage_prcs_fclt" //하수처리시설
|
||||
) {
|
||||
const cqlFilter = `ctpv_nm='${encodeURIComponent(sidoNm)}' AND sgg_nm='${encodeURIComponent(sggNm)}'`;
|
||||
url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = json.features.map((f: any) => ({
|
||||
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
|
||||
val: f.id,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
|
||||
) {
|
||||
//재용에서 참조하는 하수처리시설 목록
|
||||
const paramsRes = await fetch("/reuse-service/reuseFc/pubFc");
|
||||
const paramsJson = await paramsRes.json();
|
||||
const params = paramsJson.data;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.ruseUtztnFcltNm ?? "(이름없음)",
|
||||
val: item.pbnstRuseFcltId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_ruse_fclt_dmd_co" //수요처
|
||||
) {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.dmdInfoId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_tmpr_dmd_co" //잠재수요처
|
||||
) {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.fctryMngSn,
|
||||
}));
|
||||
} else {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.bdstNm ?? "(이름없음)",
|
||||
val: item.bdstInfoSn,
|
||||
}));
|
||||
}
|
||||
|
||||
setFacilityData([{ text: "선택", val: "0" }, ...list]);
|
||||
setFacilityValue("0");
|
||||
};
|
||||
|
||||
//시도, 시군구 초기 데이터 조회
|
||||
useEffect(() => {
|
||||
fetchSido();
|
||||
}, []);
|
||||
|
||||
//시군구 데이터 초기화
|
||||
const initializeSigunData = useCallback(
|
||||
(sidoCd: string) => {
|
||||
if (sidoCd === "0") {
|
||||
setFilteredSigunData([{ text: "선택", val: "0" }]);
|
||||
setSigunValue("0");
|
||||
return;
|
||||
}
|
||||
const prefix = sidoCd.substring(0, 2);
|
||||
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
|
||||
setFilteredSigunData([{ text: "선택", val: "0" }, ...filtered]);
|
||||
setSigunValue("0");
|
||||
},
|
||||
[sigunData]
|
||||
);
|
||||
|
||||
//콤보박스 선택시 연관 콤보박스 처리
|
||||
const onSelectValue = useCallback(
|
||||
(val: string, type: string) => {
|
||||
if (type === "ctpvCd") {
|
||||
setSidoValue(val);
|
||||
initializeSigunData(val);
|
||||
setFacilityData([{ text: "선택", val: "0" }]);
|
||||
setFacilityValue("0");
|
||||
} else if (type === "sggCd") {
|
||||
setSigunValue(val);
|
||||
const sidoNm =
|
||||
sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm = sigunData.find((item) => item.val === val)?.text || "";
|
||||
const sggCd = sigunData.find((item) => item.val === val)?.val || "";
|
||||
if (sggNm && sidoNm)
|
||||
fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType);
|
||||
} else if (type === "facilityType") {
|
||||
setFacilityType(val);
|
||||
setFacilityValue("0");
|
||||
setFacilityData([{ text: "선택", val: "0" }]);
|
||||
const sidoNm =
|
||||
sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm =
|
||||
sigunData.find((item) => item.val === sigunValue)?.text || "";
|
||||
const sggCd =
|
||||
sigunData.find((item) => item.val === sigunValue)?.val || "";
|
||||
if (sggNm && sidoNm) fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, val);
|
||||
} else if (type === "fcltCd") {
|
||||
setFacilityValue(val);
|
||||
}
|
||||
},
|
||||
[initializeSigunData, sigunData, sigunValue, sidoData, sidoValue]
|
||||
);
|
||||
|
||||
//조회 버튼 클릭
|
||||
const handleSearch = () => {
|
||||
if (!gisMaps.current || facilityValue === "0") return;
|
||||
const layers = gisMaps.current.getLayers().getArray();
|
||||
for (const layer of layers) {
|
||||
const source = layer.getSource?.();
|
||||
const feature = source?.getFeatureById?.(facilityValue);
|
||||
if (feature) {
|
||||
onFeatureSelect(feature);
|
||||
return;
|
||||
}
|
||||
}
|
||||
alert("해당 처리시설을 지도에서 찾을 수 없습니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-4">
|
||||
<div className="card card-border flex">
|
||||
<div className="col-span-10 flex flex-row">
|
||||
{facilityTypes.length > 1 && (
|
||||
<SelectBox
|
||||
labelClass="w-[120px]"
|
||||
className="mr-10 w-[190px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시설물유형"
|
||||
selectArray={[{ text: "선택", val: "0" }, ...facilityTypes]}
|
||||
onSelectValue={(val) =>
|
||||
onSelectValue(val.toString(), "facilityType")
|
||||
}
|
||||
value={facilityType}
|
||||
/>
|
||||
)}
|
||||
<SelectBox
|
||||
labelClass="w-[40px]"
|
||||
className="mr-10 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시도"
|
||||
selectArray={sidoData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
value={sidoValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[65px]"
|
||||
className="mr-10 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시군구"
|
||||
selectArray={filteredSigunData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "sggCd")}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[60px]"
|
||||
className="mr-3 w-[180px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label="시설명"
|
||||
selectArray={facilityData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "fcltCd")}
|
||||
value={facilityValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Button
|
||||
color="primary"
|
||||
className="mx-2 mt-1 ml-5"
|
||||
size={"sm"}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
"use client";
|
||||
import { SelectBox, type SelectDataType } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import SearchInputText from "./SearchInputText";
|
||||
|
||||
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
|
||||
tb_il_swage_prcs_fclt:
|
||||
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_il_swage_prcs_fclt&outputFormat=application/json&propertyName=swtrmt_fclt_cd,swtrmt_fclt_nm&cql_filter=",
|
||||
tb_wr_bdst_ruse_fclt:
|
||||
"/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000/",
|
||||
tb_wr_rnwtr_utztn_fclt:
|
||||
"/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_rswtr_fclt:
|
||||
"/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_htdfl_fclt:
|
||||
"/reuse-service/drain/facilityList/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_ruse_fclt_dmd_co:
|
||||
"/reuse-service/reuseFc/request/1/1000/{sggCd}/{fcltyNm}",
|
||||
tb_wr_tmpr_dmd_co:
|
||||
"/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}/{fcltyNm}",
|
||||
};
|
||||
|
||||
interface SearchBarProps {
|
||||
gisMaps: React.RefObject<any>;
|
||||
facilityTypes: { text: string; val: string }[];
|
||||
onFeatureFocus: any;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
gisMaps,
|
||||
facilityTypes,
|
||||
onFeatureFocus,
|
||||
}: SearchBarProps) {
|
||||
console.log('gisMaps ', gisMaps)
|
||||
console.log('onFeatureFocus ', onFeatureFocus)
|
||||
|
||||
const [sidoValue, setSidoValue] = useState("0");
|
||||
const [sigunValue, setSigunValue] = useState("0");
|
||||
const [facilityType, setFacilityType] = useState(
|
||||
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0"
|
||||
);
|
||||
const [fcltyNm, setFcltyNm] = useState("");
|
||||
const [searchList, setSearchList] = useState<{ fnm: string; fid: string }[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const [sidoData, setSidoData] = useState<SelectDataType[]>([
|
||||
{ text: "시도", val: "0" },
|
||||
]);
|
||||
const [sigunData, setSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "시군구", val: "0" },
|
||||
]);
|
||||
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
|
||||
{ text: "시군구", val: "0" },
|
||||
]);
|
||||
|
||||
const fetchSido = async () => {
|
||||
const res = await fetch("/cms-service/api/v1/code/cache/ADSTDSTRCD/Y");
|
||||
const json = await res.json();
|
||||
const sidoList = json
|
||||
.filter((item: any) => item.lwpoCd?.length === 2)
|
||||
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }));
|
||||
const sigunList = json
|
||||
.filter((item: any) => item.lwpoCd?.length > 2)
|
||||
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }));
|
||||
setSidoData([{ text: "시도", val: "0" }, ...sidoList]);
|
||||
setSigunData(sigunList);
|
||||
};
|
||||
|
||||
const initializeSigunData = useCallback(
|
||||
(sidoCd: string) => {
|
||||
if (sidoCd === "0") {
|
||||
setFilteredSigunData([{ text: "시도", val: "0" }]);
|
||||
setSigunValue("0");
|
||||
return;
|
||||
}
|
||||
const prefix = sidoCd.substring(0, 2);
|
||||
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
|
||||
setSigunValue("0");
|
||||
},
|
||||
[sigunData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSido();
|
||||
}, []);
|
||||
|
||||
const onSelectValue = useCallback(
|
||||
(val: string, type: string) => {
|
||||
if (type === "ctpvCd") {
|
||||
setSidoValue(val);
|
||||
initializeSigunData(val);
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
} else if (type === "sggCd") {
|
||||
setSigunValue(val);
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
} else if (type === "facilityType") {
|
||||
setFacilityType(val);
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
}
|
||||
},
|
||||
[initializeSigunData]
|
||||
);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!fcltyNm) return alert("시설명을 입력하세요.");
|
||||
|
||||
const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || "";
|
||||
|
||||
const type = facilityType;
|
||||
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
|
||||
if (!baseUrlTemplate) return;
|
||||
|
||||
let url = baseUrlTemplate;
|
||||
const year = new Date().getFullYear().toString();
|
||||
url = url
|
||||
.replace("{year}", year)
|
||||
.replace("{sggCd}", sigunValue)
|
||||
.replace("{sidoNm}", encodeURIComponent(sidoNm))
|
||||
.replace("{sggNm}", encodeURIComponent(sggNm))
|
||||
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
|
||||
|
||||
let list: SelectDataType[] = [];
|
||||
|
||||
if (type === "tb_il_swage_prcs_fclt") {
|
||||
const cqlFilter = `ctpv_nm='${sidoNm}' and sgg_nm='${sggNm}' and swtrmt_fclt_nm like '%${fcltyNm}%'`;
|
||||
url += encodeURIComponent(cqlFilter);
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = json.features.map((f: any) => ({
|
||||
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
|
||||
val: f.id,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
|
||||
) {
|
||||
//재이용에서 참조하는 하수처리시설 목록
|
||||
const procRes = await fetch("/reuse-service/reuseFc/pubFc");
|
||||
const procJson = await procRes.json();
|
||||
const procData = procJson.data;
|
||||
const procResult = procData
|
||||
.filter((code: string) => code.slice(0, 5) === sigunValue)
|
||||
.map((code: string) => {
|
||||
return code;
|
||||
});
|
||||
|
||||
//처리시설명으로 처리시설코드 조회
|
||||
let procUrl2 =
|
||||
"/operation-service/prcsFclt/ruseInfo/1/1000/A/{sggCd}/0/1/SW/{fcltyNm}";
|
||||
procUrl2 = procUrl2
|
||||
.replace("{sggCd}", sigunValue)
|
||||
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
|
||||
const procRes2 = await fetch(procUrl2, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(procResult),
|
||||
});
|
||||
const procJson2 = await procRes2.json();
|
||||
const procResult2 = (procJson2.data || []).map((item: any) => item.fid);
|
||||
|
||||
//최종 처리시설코드로 재이용시설조회
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(procResult2),
|
||||
});
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.ruseUtztnFcltNm ?? "(이름없음)",
|
||||
val: item.pbnstRuseFcltId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_ruse_fclt_dmd_co" //수요처
|
||||
) {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.dmdInfoId,
|
||||
}));
|
||||
} else if (
|
||||
type === "tb_wr_tmpr_dmd_co" //잠재수요처
|
||||
) {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.dmdCoNm ?? "(이름없음)",
|
||||
val: item.fctryMngSn,
|
||||
}));
|
||||
} else {
|
||||
// GET 방식
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
list = (json.data || []).map((item: any) => ({
|
||||
text: item.bdstNm ?? "(이름없음)",
|
||||
val: item.bdstInfoSn,
|
||||
}));
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
alert("검색 결과가 없습니다.");
|
||||
setSearchList([]);
|
||||
setFcltyNm("");
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = list.map((item: any) => {
|
||||
const code = item.val;
|
||||
const name = item.text;
|
||||
return { fnm: name, fid: code };
|
||||
});
|
||||
setSearchList(newList);
|
||||
};
|
||||
|
||||
const moveFocus = (feature: any) => {
|
||||
console.log('feature ' ,feature)
|
||||
|
||||
// console.log('searchbar:moveFocus')
|
||||
// const map = gisMaps.current;
|
||||
// if (!map) {
|
||||
// console.warn("지도가 초기화되지 않았습니다.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // 항상 같은 select 객체 사용
|
||||
// const select = GisApp.Module.select as any;
|
||||
// if (!select) return;
|
||||
|
||||
// // 1. 기존 선택 초기화
|
||||
// select.resetSelectedFeature();
|
||||
|
||||
// // 2. data가 OpenLayers Feature 인스턴스인지 확인
|
||||
// let foundFeature: any = null;
|
||||
// let featureId = data.fid;
|
||||
// const layerName = data.layerNm;
|
||||
// map
|
||||
// .getLayers()
|
||||
// .getArray()
|
||||
// .forEach((layer: any) => {
|
||||
// const source = layer.getSource?.();
|
||||
// if (source?.getFeatureById) {
|
||||
// const feature = source.getFeatureById(String(featureId).trim());
|
||||
// if (feature) foundFeature = feature;
|
||||
// }
|
||||
// });
|
||||
|
||||
// // 3. 피처 선택
|
||||
// if (foundFeature) {
|
||||
// const geometry = foundFeature.getGeometry();
|
||||
// if (geometry) {
|
||||
// const extent = geometry.getExtent();
|
||||
|
||||
// map.getView().fit(extent, {
|
||||
// duration: 300,
|
||||
// padding: [50, 50, 50, 50],
|
||||
// maxZoom: 16,
|
||||
// constrainResolution: true,
|
||||
// nearest: true,
|
||||
// });
|
||||
// }
|
||||
// // select.selectFeature(foundFeature, true);
|
||||
// } else {
|
||||
// console.warn("OpenLayers Feature를 찾을 수 없습니다:", data);
|
||||
// }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-4">
|
||||
<div className="card card-border flex">
|
||||
<div className="col-span-10 flex flex-row">
|
||||
{facilityTypes.length > 1 && (
|
||||
<SelectBox
|
||||
labelClass="w-[120px]"
|
||||
className="mr-5 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={[
|
||||
{ text: "시설 구분", val: "0" },
|
||||
...facilityTypes,
|
||||
]}
|
||||
onSelectValue={(val) =>
|
||||
onSelectValue(val.toString(), "facilityType")
|
||||
}
|
||||
value={facilityType}
|
||||
/>
|
||||
)}
|
||||
<SelectBox
|
||||
labelClass="w-[40px]"
|
||||
className="mr-5 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={sidoData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
value={sidoValue}
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[65px]"
|
||||
className="mr-5 w-[150px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={filteredSigunData}
|
||||
onSelectValue={(val) => onSelectValue(val.toString(), "sggCd")}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<SearchInputText
|
||||
initValue={fcltyNm}
|
||||
setValue={(val: string = "") => {
|
||||
setFcltyNm(val);
|
||||
}}
|
||||
setRtnData={(selected: { fnm: string; fid: string }) => {
|
||||
// 선택된 항목이 있을 경우 onFeatureSelect 실행
|
||||
const type = facilityType;
|
||||
moveFocus({
|
||||
layerNm: type,
|
||||
...selected,
|
||||
});
|
||||
}}
|
||||
searchList={searchList}
|
||||
onEnter={handleSearch}
|
||||
className="mr-5 w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Button
|
||||
color="primary"
|
||||
className="mx-2 mt-1"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getFetch } from "@/components/lib/api";
|
||||
import type { FacilityInfo, SubsInfo } from "./type";
|
||||
import { DEV_URL_FO } from "@/components/lib/config";
|
||||
|
||||
const checkWrRegList = async () => {
|
||||
const wrRegList = await getFetch("wr", `/reuseFc/pubFc`);
|
||||
return wrRegList;
|
||||
};
|
||||
|
||||
const getRuseFclt = async (
|
||||
page: string,
|
||||
sggOrCity: string,
|
||||
searchTxt?: string
|
||||
) => {
|
||||
const checkWrRegListRes = await checkWrRegList();
|
||||
|
||||
let url = `/prcsFclt/ruseInfo/${page ? page : 1}/5/A/${sggOrCity ? sggOrCity : "0"}/0/1/SW`;
|
||||
|
||||
const ruseList = checkWrRegListRes.filter((item: string) => item !== "");
|
||||
|
||||
if (searchTxt) {
|
||||
url = url + `/${searchTxt}`;
|
||||
}
|
||||
|
||||
const response = await fetch(DEV_URL_FO + url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(ruseList),
|
||||
});
|
||||
|
||||
const hasuList = await response.json().then((res: any) => {
|
||||
const data = res.data
|
||||
.map((item: any) => {
|
||||
return {
|
||||
...item,
|
||||
fctCpc: Number(item.fctCpc) || 0,
|
||||
searchNm: item.fctNm,
|
||||
searchPk: item.bscFctNm,
|
||||
};
|
||||
})
|
||||
.filter((hasu: any) => ruseList.find((ruse: any) => hasu.bscFctCd === ruse));
|
||||
return { data: data, count: res.count };
|
||||
});
|
||||
|
||||
return hasuList;
|
||||
};
|
||||
|
||||
export type DmMapActions = {
|
||||
findRuseFclt(sggOrCity: string, searchTxt: string): void;
|
||||
setSubsInfo(subsInfo: SubsInfo[]): void;
|
||||
};
|
||||
|
||||
export type DmMapType = {
|
||||
map: {
|
||||
ruseFcltList: FacilityInfo[];
|
||||
subsInfo: SubsInfo[];
|
||||
};
|
||||
};
|
||||
|
||||
export const dmMapState: DmMapType = {
|
||||
map: {
|
||||
ruseFcltList: [],
|
||||
subsInfo: []
|
||||
},
|
||||
};
|
||||
export type DmMapStore = DmMapType & DmMapActions;
|
||||
|
||||
export const DmMapReducer = (set: any): DmMapActions => {
|
||||
return {
|
||||
findRuseFclt: async (sggOrCity: string, searchTxt: string) => {
|
||||
const ruseFcltList = await getRuseFclt("1", sggOrCity, searchTxt);
|
||||
|
||||
set((state: DmMapType) => ({
|
||||
...state,
|
||||
map: {
|
||||
...state.map,
|
||||
ruseFcltList: ruseFcltList.data,
|
||||
},
|
||||
}));
|
||||
},
|
||||
setSubsInfo: async (subsInfo: SubsInfo[]) => {
|
||||
set((state: DmMapType) => ({
|
||||
...state,
|
||||
map: {
|
||||
...state.map,
|
||||
subsInfo: subsInfo,
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
export type FacilityInfo= {
|
||||
bscFctCd: string; // 기본시설 코드
|
||||
bscFctNm: string; // 기본시설명
|
||||
rvbsnCd: string; // 유역 코드
|
||||
rvbsn: string; // 유역명
|
||||
lgbsn: string; // 대권역명
|
||||
mdmbsn: string; // 중권역명
|
||||
rgnCd: string; // 관할청 코드
|
||||
rgnNm: string; // 관할청 명칭
|
||||
adstDstrCd: string; // 행정구역 코드
|
||||
sido: string; // 시도
|
||||
gugun: string; // 구군
|
||||
addr: string; // 주소
|
||||
fctCpc: string; // 시설용량
|
||||
efflWqltStndrdCd: string; // 방류수 수질기준 코드
|
||||
efflWqltStndrdNm: string; // 방류수 수질기준명
|
||||
trtFrfNm: string; // 처리공법명
|
||||
trtDstr: string; // 처리구역
|
||||
rnDt: string; // 사용개시일 (예: YYYYMMDD)
|
||||
cmplDt: string; // 준공일자 (예: YYYYMMDD)
|
||||
trstOprEtpNm: string; // 위탁운영기관명
|
||||
bscFctClsNm: string; // 기본시설 구분명
|
||||
sitArea: string; // 부지면적
|
||||
trtDstrArea: string; // 처리구역 면적
|
||||
swrTrtPopl: string; // 하수처리 인구
|
||||
mnstr: string; // 총괄 유역
|
||||
efflWtrrgn: string; // 방류수역
|
||||
rvNm: string; // 하천명
|
||||
bscFctClsCd: string; // 기본시설 구분 코드
|
||||
rvbsnNm: string; // 유역명
|
||||
tel: string; // 전화번호
|
||||
rswtrYn: string; // 재이용수 여부 ("Y" 또는 "N")
|
||||
ruse: boolean; // 재이용 여부 (boolean)
|
||||
}
|
||||
|
||||
export type SubsInfo={
|
||||
ruseUtztnFcltNm: string; // 재이용시설명
|
||||
ruseUtztnFcltCd: string; // 재이용시설 코드
|
||||
}
|
||||
@@ -79,11 +79,11 @@ export const DmdCoReqSchema = z.object({
|
||||
grssClfg: z.number().nullish(),
|
||||
combResdch: z.number().nullish(),
|
||||
trbt: z.number().nullish(),
|
||||
smll: z.number().nullish(),
|
||||
smll: z.string().nullish(),
|
||||
crmty: z.number().nullish(),
|
||||
tn: z.number().nullish(),
|
||||
tph: z.number().nullish(),
|
||||
ph: z.number().nullish(),
|
||||
ph: z.string().nullish(),
|
||||
clIonQnt: z.number().nullish(),
|
||||
cdtnv: z.number().nullish(),
|
||||
rmrk: z.string().nullish(),
|
||||
|
||||
@@ -161,7 +161,6 @@ import {
|
||||
type rswtStatState,
|
||||
type rswtStatType,
|
||||
} from "./stats/rswtStat";
|
||||
import { LoginApi } from "@/src/entities/auth";
|
||||
import {
|
||||
authActionsExport,
|
||||
authStateExport,
|
||||
@@ -216,6 +215,12 @@ import {
|
||||
type ldrmntMapState,
|
||||
type ldrmntMapType,
|
||||
} from "./map/ldrmntCoMap";
|
||||
import {
|
||||
DmMapReducer,
|
||||
dmMapState,
|
||||
type DmMapType,
|
||||
type DmMapStore,
|
||||
} from "./map/splyPop";
|
||||
|
||||
export type WrStore = comCodeType &
|
||||
pubFacType &
|
||||
@@ -251,6 +256,7 @@ export type WrStore = comCodeType &
|
||||
ruseMapType &
|
||||
coSttnMapType &
|
||||
ldrmntMapType &
|
||||
DmMapStore &
|
||||
authType;
|
||||
|
||||
export const StoreZus = (
|
||||
@@ -288,6 +294,7 @@ export const StoreZus = (
|
||||
ruseMapState &
|
||||
coSttnMapState &
|
||||
ldrmntMapState &
|
||||
DmMapType &
|
||||
authState = {
|
||||
...comCodeStateExport,
|
||||
...pubFacStateExport,
|
||||
@@ -324,6 +331,7 @@ export const StoreZus = (
|
||||
...ruseMapStateExport,
|
||||
...coSttnMapStateExport,
|
||||
...ldrmntMapStateExport,
|
||||
...dmMapState,
|
||||
}
|
||||
) => {
|
||||
return createStore<WrStore>()((set) => {
|
||||
@@ -333,7 +341,6 @@ export const StoreZus = (
|
||||
getMiddleRegionCd(set);
|
||||
getUnitWaterShedCd(set);
|
||||
getEnvPartCd(set);
|
||||
LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" });
|
||||
return {
|
||||
...initState,
|
||||
...comFcltSearchAction(set),
|
||||
@@ -371,6 +378,7 @@ export const StoreZus = (
|
||||
...ruseMapActionsExport(set),
|
||||
...coSttnMapActionsExport(set),
|
||||
...ldrmntMapActionsExport(set),
|
||||
...DmMapReducer(set),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SERVER_API_CS_URL,
|
||||
SERVER_API_FO_URL,
|
||||
SERVER_API_WR_URL,
|
||||
SERVER_SEWERAGE_API_URL,
|
||||
} from "@/src/shared/config";
|
||||
|
||||
//const DEV_PORT = "11080";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
export const LOCAL_URL_WR = PROXY_HOST + `/api/v1`;
|
||||
export const DEV_URL_WR = SERVER_API_WR_URL;
|
||||
export const DEV_URL_FO = SERVER_API_FO_URL;
|
||||
export const DEV_URL_SN = SERVER_SEWERAGE_API_URL;
|
||||
export const LOCAL_URL = PROXY_HOST;
|
||||
export const CS_URL = SERVER_API_CS_URL;
|
||||
export const CS_TUS_UPLOAD = SERVER_API_CS_TUS_URL;
|
||||
|
||||
@@ -67,8 +67,21 @@ export default function ConCostForm({
|
||||
useEffect(() => {
|
||||
// 다른 행을 선택하면(selectionVersion 변경) 체크박스 상태를 초기화합니다.
|
||||
setCheckFinish(false);
|
||||
setLastSub(null)
|
||||
}, [selectionVersion]);
|
||||
|
||||
//공급처 팝업 값 셋팅
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === "SET_SUBS_INFO") {
|
||||
setLastSub(event.data.payload);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [setLastSub]);
|
||||
|
||||
const zipDowonLoad = () => {
|
||||
fetch(`/2024_constarctFeeProgram.zip`, {}).then(async (response) => {
|
||||
const blobRet = await response.blob();
|
||||
@@ -161,11 +174,12 @@ export default function ConCostForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const splycd = form.getValues("splyCndtList");
|
||||
const pk =
|
||||
splycd && splycd[0]?.ruseUtztnFcltCd
|
||||
? splycd[0]?.ruseUtztnFcltCd
|
||||
: "11000SW001R1";
|
||||
// const pk =
|
||||
// splycd && splycd[0]?.ruseUtztnFcltCd
|
||||
// ? splycd[0]?.ruseUtztnFcltCd
|
||||
// : "11000SW001R1";
|
||||
|
||||
const pk =lastSub?.ruseUtztnFcltCd??null
|
||||
|
||||
if (type === "mat") {
|
||||
if (value.demandCst) {
|
||||
@@ -178,6 +192,9 @@ export default function ConCostForm({
|
||||
flg: checkFinish ? "Checked" : "Sub",
|
||||
rgtr: TEST_UUID,
|
||||
};
|
||||
|
||||
if(pk !==null){
|
||||
|
||||
if (checkFinish) {
|
||||
toast((t) => (
|
||||
<span className="flex flex-col items-center text-2xl">
|
||||
@@ -204,6 +221,9 @@ export default function ConCostForm({
|
||||
} else {
|
||||
coMatRvwUpdate(pk, form.getValues("dmdInfoId"), data);
|
||||
}
|
||||
}else{
|
||||
toast.error('후보지를 먼저 선택해주세요')
|
||||
}
|
||||
}
|
||||
} else if (type === "hist") {
|
||||
delete value.totalConstCost;
|
||||
@@ -219,15 +239,62 @@ export default function ConCostForm({
|
||||
}, [form.formState]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("splyCndtList ", splyList);
|
||||
const origin = window.location.origin;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
// 보안: 동일 출처만 수신
|
||||
if (event.origin !== origin) return;
|
||||
|
||||
// 구조 안전 체크
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
|
||||
if (data.type === "REQUEST_INIT_SUBS_INFO") {
|
||||
const initSubs =
|
||||
(splyList ?? [])
|
||||
.filter(Boolean)
|
||||
.map((x: any) => ({
|
||||
ruseUtztnFcltNm: x.ruseUtztnFcltNm ?? "",
|
||||
ruseUtztnFcltCd: x.ruseUtztnFcltCd ?? "",
|
||||
}));
|
||||
|
||||
// 응답: 옵션 객체 형태로 전송 (TS 에러 방지)
|
||||
const target = event.source as Window | null;
|
||||
target?.postMessage(
|
||||
{ type: "INIT_SUBS_INFO", payload: initSubs },
|
||||
{ targetOrigin: origin }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [splyList]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
form.register("dmdInfoSn");
|
||||
form.register("dmdInfoId");
|
||||
form.register("ruseUtztnFcltNm");
|
||||
}, []);
|
||||
|
||||
|
||||
const searchCandidate = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const dmdInfoId = form.getValues("dmdInfoId");
|
||||
console.log(dmdInfoId)
|
||||
|
||||
const url = `/splyPopup?dmdInfoId=${encodeURIComponent(dmdInfoId)}`;
|
||||
const width = window.screen.availWidth;
|
||||
const height = window.screen.availHeight;
|
||||
const left = 0;
|
||||
const top = 0;
|
||||
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
|
||||
window.open(url, "candidatePopup", popupOptions);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr className="mt-8" />
|
||||
@@ -516,6 +583,8 @@ export default function ConCostForm({
|
||||
console.log(e);
|
||||
setLastSub(null) // 빌드오류때문에 넣은거 삭제필요
|
||||
|
||||
searchCandidate(e);
|
||||
|
||||
//splyList => 해당객체가 대민에서 선택한 후보처 리스트
|
||||
//setLastSub => 해당 스테이트가 선택한 마지막 후보처를 넣어야하는 스테이트
|
||||
}}
|
||||
|
||||
+1
-13
@@ -16,15 +16,6 @@ export function middleware(req: NextRequest) {
|
||||
console.log(`[Middleware] Request URL: ${req.url}`);
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
|
||||
// if (pathname.startsWith("/server")) {
|
||||
// const queryParams = searchParams.toString();
|
||||
// const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
|
||||
// queryParams ? `?${queryParams}` : ""
|
||||
// }`;
|
||||
// console.log(`[Middleware] Rewriting from '/server' path to: ${newUrl}`);
|
||||
// return NextResponse.rewrite(new URL(newUrl));
|
||||
// }
|
||||
|
||||
if (pathname.startsWith("/server")) {
|
||||
const decodedParams = Array.from(searchParams.entries())
|
||||
.map(([key, value]) => `${key}=${decodeURIComponent(value)}`)
|
||||
@@ -37,7 +28,6 @@ export function middleware(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (pathname === "/") {
|
||||
console.log(" maim 화면이동 ");
|
||||
return NextResponse.redirect(new URL("/map/mainMap", req.url));
|
||||
}
|
||||
|
||||
@@ -46,7 +36,6 @@ export function middleware(req: NextRequest) {
|
||||
const Authorization = req.cookies.get(CLAIM_NAME)?.value;
|
||||
const newUrl = `${serverApiUrl}${pathname}`;
|
||||
|
||||
console.log("Authorization cms-service ", Authorization);
|
||||
if (Authorization) requestHeaders.set(CLAIM_NAME, Authorization);
|
||||
//if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
@@ -90,7 +79,6 @@ export function middleware(req: NextRequest) {
|
||||
if (pathname.startsWith("/geoserver")) {
|
||||
let queryParams = searchParams.toString();
|
||||
const typename = searchParams.get("TYPENAME");
|
||||
console.log("typename ", typename);
|
||||
const startYmd = searchParams.get("START_YMD") ?? null;
|
||||
const endYmd = searchParams.get("END_YMD") ?? null;
|
||||
|
||||
@@ -129,7 +117,7 @@ export function middleware(req: NextRequest) {
|
||||
newUrl = `${SEWERAGE_API_URL}/v1/map/geometry${queryParams ? `?${queryParams}` : ""}`;
|
||||
}
|
||||
|
||||
console.log(`[Middleware] Rewriting from '/geoserver' path to: ${newUrl}`);
|
||||
//console.log(`[Middleware] Rewriting from '/geoserver' path to: ${newUrl}`);
|
||||
return NextResponse.rewrite(new URL(newUrl));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"start:sn": "next start -p 3001",
|
||||
"start:wr": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -21,6 +23,8 @@
|
||||
"lucide-react": "^0.474.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "15.1.7",
|
||||
"ol": "^7.3.0",
|
||||
"ol-ext": "^4.0.11",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-i18next": "^15.6.0",
|
||||
|
||||
+4
-1
@@ -9,10 +9,13 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build --filter=!dm",
|
||||
"build": "turbo run build",
|
||||
"build:common": "turbo run build --filter=@common/*",
|
||||
"dev": "turbo run dev",
|
||||
"build:sn": "turbo run build --filter=sn",
|
||||
"build:wr": "turbo run build --filter=wr",
|
||||
"build:fo": "turbo run build --filter=fo",
|
||||
"build:dm": "turbo run build --filter=dm",
|
||||
"dev:cs": "turbo run dev --filter=cs",
|
||||
"dev:sn": "turbo run dev --filter=sn",
|
||||
"dev:wr": "turbo run dev --filter=wr",
|
||||
|
||||
@@ -159,7 +159,6 @@ function Gis({
|
||||
// 피처 클릭시
|
||||
const handleFeatureSelect = useCallback(
|
||||
(data) => {
|
||||
console.log("handleFeatureSelect");
|
||||
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
|
||||
},
|
||||
[mapInstanceRef],
|
||||
@@ -169,8 +168,6 @@ function Gis({
|
||||
const handleFeatureFocus = useCallback(
|
||||
(layerName, data) => {
|
||||
console.log("handleFeatureFocus");
|
||||
console.log(layerName);
|
||||
console.log(data);
|
||||
// onFeatureFocus?.(data);
|
||||
},
|
||||
[mapInstanceRef],
|
||||
@@ -194,7 +191,6 @@ function Gis({
|
||||
|
||||
// OpenLayers 지도 객체 생성
|
||||
if (!map) {
|
||||
console.log("지도 객체 생성");
|
||||
map = new OlMap({
|
||||
layers: [gisCore.baseLayers["base"], gisCore.baseLayers["satellite"]],
|
||||
renderer: "webgl",
|
||||
@@ -222,14 +218,12 @@ function Gis({
|
||||
} else {
|
||||
// 📌 재마운트 시 target 없으면 다시 지정
|
||||
if (!map.getTarget()) {
|
||||
console.log("재마운트");
|
||||
map.setTarget(mapRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
const select = GisApp.Module.select;
|
||||
if (select) {
|
||||
console.log("select 바인딩");
|
||||
if (select.selectInteraction) {
|
||||
map.removeInteraction(select.selectInteraction);
|
||||
}
|
||||
@@ -264,7 +258,6 @@ function Gis({
|
||||
|
||||
// 📌 언마운트 시 완전 제거
|
||||
return () => {
|
||||
console.log("지도 제거");
|
||||
map.setTarget(null); // DOM 연결 해제
|
||||
mapInstanceRef.current = null; // 참조 초기화
|
||||
};
|
||||
@@ -361,12 +354,7 @@ function Gis({
|
||||
layerNmRef.current = layerNm;
|
||||
setLayerNm(layerNm);
|
||||
setIsOpen(true);
|
||||
const dummyFeature = {
|
||||
getProperties: () => ({}), // 최소 구조
|
||||
};
|
||||
console.log(layerNm)
|
||||
onOpenPopup(layerNm)
|
||||
// onFeatureSelect(dummyFeature);
|
||||
}}
|
||||
imageVisible={imgVisble}
|
||||
isPlaying={isPlaying}
|
||||
@@ -390,7 +378,7 @@ function Gis({
|
||||
)}
|
||||
|
||||
{/* DetailView가 존재할 때만 렌더링 isSearchModalOpen이 true면 visible=false로 팝업 숨김 */}
|
||||
{isOpen && !isSearchModalOpen && (
|
||||
{isOpen && !isSearchModalOpen && DetailView && (
|
||||
<DetailView
|
||||
featureData={featureData || { getProperties: () => ({}) }}
|
||||
seletectedLayerNm={layerNm}
|
||||
|
||||
@@ -16,7 +16,6 @@ export default React.memo(function GisSelect({ onFeatureSelect }) {
|
||||
// 피처 선택 시 콜백 등록
|
||||
select.getSelectedInfo(function (features) {
|
||||
if (features?.[0]) {
|
||||
console.log("GisSelect features", features);
|
||||
onFeatureSelect(features[0]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -402,7 +402,7 @@ import {
|
||||
activeTab === "layers" ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600">레이어</h3>
|
||||
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
|
||||
<hr className="mb-4" />
|
||||
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
|
||||
<Tree
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
|
||||
text: f.properties.mctpv_korn_nm,
|
||||
val: f.properties.mctpv_cd,
|
||||
}));
|
||||
setSidoData([{ text: "선택", val: "0" }, ...list]);
|
||||
setSidoData([{ text: "시도", val: "0" }, ...list]);
|
||||
};
|
||||
|
||||
const fetchSigungu = async () => {
|
||||
@@ -249,6 +249,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
|
||||
nearest: true,
|
||||
});
|
||||
} else {
|
||||
alert("위치정보를 찾을 수 없습니다.")
|
||||
console.warn("geometry를 찾을 수 없습니다:", data);
|
||||
}
|
||||
} else {
|
||||
@@ -260,7 +261,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-4">
|
||||
<div className="card card-border flex">
|
||||
<div className="col-span-10 flex flex-row">
|
||||
<div className="col-span-10 flex flex-row ml-5">
|
||||
<SelectBox
|
||||
labelClass="w-[40px]"
|
||||
className="mr-10 w-[150px]"
|
||||
@@ -285,7 +286,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
|
||||
/>
|
||||
<SelectBox
|
||||
labelClass="w-[60px]"
|
||||
className="mr-3 w-[180px]"
|
||||
className="mr-5 w-[180px]"
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={facilityData}
|
||||
|
||||
@@ -69,7 +69,7 @@ export const SearchBar = ({
|
||||
const initializeSigunData = useCallback(
|
||||
(sidoCd: string) => {
|
||||
if (sidoCd === "0") {
|
||||
setFilteredSigunData([{ text: "시도", val: "0" }]);
|
||||
setFilteredSigunData([{ text: "시군구", val: "0" }]);
|
||||
setSigunValue("0");
|
||||
return;
|
||||
}
|
||||
@@ -110,7 +110,6 @@ export const SearchBar = ({
|
||||
|
||||
const sidoNm = sidoData.find((item) => item.val === sidoValue)?.text || "";
|
||||
const sggNm = sigunData.find((item) => item.val === sigunValue)?.text || "";
|
||||
|
||||
const type = facilityType;
|
||||
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
|
||||
if (!baseUrlTemplate) return;
|
||||
@@ -149,8 +148,7 @@ export const SearchBar = ({
|
||||
});
|
||||
|
||||
//처리시설명으로 처리시설코드 조회
|
||||
let procUrl2 =
|
||||
"/operation-service/prcsFclt/ruseInfo/1/1000/A/{sggCd}/0/1/SW/{fcltyNm}";
|
||||
let procUrl2 = "/operation-service/prcsFclt/ruseInfo/1/1000/A/{sggCd}/0/1/SW/{fcltyNm}";
|
||||
procUrl2 = procUrl2
|
||||
.replace("{sggCd}", sigunValue)
|
||||
.replace("{fcltyNm}", encodeURIComponent(fcltyNm));
|
||||
@@ -163,7 +161,7 @@ export const SearchBar = ({
|
||||
body: JSON.stringify(procResult),
|
||||
});
|
||||
const procJson2 = await procRes2.json();
|
||||
const procResult2 = (procJson2.data || []).map((item: any) => item.fid);
|
||||
const procResult2 = (procJson2.data || []).map((item: any) => item.bscFctCd);
|
||||
|
||||
//최종 처리시설코드로 재이용시설조회
|
||||
const res = await fetch(url, {
|
||||
@@ -179,6 +177,7 @@ export const SearchBar = ({
|
||||
text: item.ruseUtztnFcltNm ?? "(이름없음)",
|
||||
val: item.pbnstRuseFcltId,
|
||||
}));
|
||||
|
||||
} else if (
|
||||
type === "tb_wr_ruse_fclt_dmd_co" //수요처
|
||||
) {
|
||||
@@ -225,6 +224,23 @@ export const SearchBar = ({
|
||||
|
||||
//해당 시설물로 이동
|
||||
const handleFeatureFocus = (data: any) => {
|
||||
|
||||
let featureId = data.fid;
|
||||
const layerNm = data.layerNm;
|
||||
|
||||
if (!isNaN(Number(featureId))) {
|
||||
// 숫자면 문자열로 변환
|
||||
featureId = String(featureId);
|
||||
}
|
||||
|
||||
if (layerNm === "tb_wr_bdst_ruse_fclt") {
|
||||
featureId = featureId.slice(0, 11);
|
||||
}
|
||||
|
||||
if (!featureId.includes(".")) {
|
||||
featureId = layerNm + "." + featureId;
|
||||
}
|
||||
|
||||
const map = gisMaps.current;
|
||||
if (!map) {
|
||||
console.warn("지도가 초기화되지 않았습니다.");
|
||||
@@ -237,7 +253,7 @@ export const SearchBar = ({
|
||||
//select.resetSelectedFeature();
|
||||
// 2. data가 OpenLayers Feature 인스턴스인지 확인
|
||||
let foundFeature: any = null;
|
||||
let featureId = data.fid;
|
||||
|
||||
map
|
||||
.getLayers()
|
||||
.getArray()
|
||||
@@ -261,6 +277,7 @@ export const SearchBar = ({
|
||||
nearest: true,
|
||||
});
|
||||
} else {
|
||||
alert("위치정보를 찾을 수 없습니다.");
|
||||
console.warn("geometry를 찾을 수 없습니다:", data);
|
||||
}
|
||||
} else {
|
||||
@@ -285,7 +302,7 @@ export const SearchBar = ({
|
||||
{ text: "시설 구분", val: "0" },
|
||||
...facilityTypes,
|
||||
]}
|
||||
onSelectValue={(val:any) =>
|
||||
onSelectValue={(val: any) =>
|
||||
onSelectValue(val.toString(), "facilityType")
|
||||
}
|
||||
value={facilityType}
|
||||
@@ -297,7 +314,7 @@ export const SearchBar = ({
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={sidoData}
|
||||
onSelectValue={(val:any) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
onSelectValue={(val: any) => onSelectValue(val.toString(), "ctpvCd")}
|
||||
value={sidoValue}
|
||||
/>
|
||||
<SelectBox
|
||||
@@ -306,7 +323,7 @@ export const SearchBar = ({
|
||||
selectClass="rounded-lg bg-white"
|
||||
label=""
|
||||
selectArray={filteredSigunData}
|
||||
onSelectValue={(val:any) => onSelectValue(val.toString(), "sggCd")}
|
||||
onSelectValue={(val: any) => onSelectValue(val.toString(), "sggCd")}
|
||||
value={sigunValue}
|
||||
/>
|
||||
<SearchInputText
|
||||
|
||||
Generated
+11353
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user