Merge branch 'iipbs/sn/dev' into iipbs/sn/ii

This commit is contained in:
swcho
2025-08-11 13:42:13 +09:00
80 changed files with 18213 additions and 1567 deletions
+1 -1
View File
@@ -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");
+1
View File
@@ -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();
+19
View File
@@ -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();
+17
View File
@@ -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();
+1
View File
@@ -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>
);
}
+187
View File
@@ -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 };
+669
View File
@@ -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 };
+396
View File
@@ -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 };
+71
View File
@@ -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 };
+1
View File
@@ -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",
+1
View File
@@ -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"
},
+1 -1
View File
@@ -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 };
});
+11 -1
View File
@@ -1,17 +1,27 @@
"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>
<StoreProvider>
{children}
{children}
</StoreProvider>
</body>
</html>
+15 -1
View File
@@ -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>
);
}
+198 -40
View File
@@ -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
| {
resetSelectedFeature: () => void;
selectFeature: (feature: FeatureData, selected: boolean) => void;
}
| undefined;
type SelectModule = {
resetSelectedFeature: () => void;
selectFeature: (feature: FeatureData, selected: boolean) => void;
};
const maybeSelect = GisApp?.Module?.select; // 타입 그대로 둠 (any | unknown | null 가능)
if (!maybeSelect) {
// null/undefined면 일찍 반환
return;
}
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>
-1
View File
@@ -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) => ({
-2
View File
@@ -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),
+4 -1
View File
@@ -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");
}
+2 -1
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
+21 -27
View File
@@ -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 (
-153
View File
@@ -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,9 +389,9 @@ 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}`
: "불검출"
: "불검출"
}
readOnly
/>
@@ -13,20 +13,20 @@ import { useEffect, useState, useRef } from "react";
import { toast, Toaster } from "react-hot-toast";
export default function DrgtSttn() {
const { code, drgt, drgtSearch, drgtModalSearch, drgtInit } = useWrStore((state) => state);
const { drgtRes, drgtMoRes } = drgt;
const [fromDate, setFromDate] = useState<string>('202506');
const [toDate, setToDate] = useState<string>('202506');
const [selectSgg, setSelectSgg] = useState<SelectDataType[]>([{ val: '0', text: '전체' }]);
const [objSearch, setObjSearch] = useState<objSearchType>({ sgg: "", city: "" });
const [tableData, setTableData] = useState<customDrgt[]>();
const [selectedSgg, setSelectedSgg] = useState<string>('');
const [excelData, setExcelData] = useState<any[]>();
const [drgtModal, setDrgtModal] = useState<{
isOpen: boolean;
data: customDrgt[] | undefined;
}>({ data: undefined, isOpen: false });
const popupRef = useRef<Window | null>(null);
const { code, drgt, drgtSearch, drgtModalSearch, drgtInit } = useWrStore((state) => state);
const { drgtRes, drgtMoRes } = drgt;
const [fromDate, setFromDate] = useState<string>('202506');
const [toDate, setToDate] = useState<string>('202506');
const [selectSgg, setSelectSgg] = useState<SelectDataType[]>([{ val: '0', text: '전체' }]);
const [objSearch, setObjSearch] = useState<objSearchType>({ sgg: "", city: "" });
const [tableData, setTableData] = useState<customDrgt[]>();
const [selectedSgg, setSelectedSgg] = useState<string>('');
const [excelData, setExcelData] = useState<any[]>();
const [drgtModal, setDrgtModal] = useState<{
isOpen: boolean;
data: customDrgt[] | undefined;
}>({ data: undefined, isOpen: false });
const popupRef = useRef<Window | null>(null);
const cellClick = (index: number) => {
if (fromDate && toDate && drgtRes[index]) {
@@ -112,101 +112,102 @@ export default function DrgtSttn() {
setObjSearch({ ...objSearch, sgg: String(val) });
};
useEffect(() => {
if (fromDate === undefined && toDate === undefined) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const formatted = `${year}${month}`;
setFromDate(formatted);
setToDate(formatted);
useEffect(() => {
if (fromDate === undefined && toDate === undefined) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const formatted = `${year}${month}`;
setFromDate(formatted);
setToDate(formatted);
}
}, [fromDate, toDate])
function onViewMapClick() {
const params = new URLSearchParams({
ctpvCd: String(objSearch.city),
sggCd: String(objSearch.sgg),
fromDate: fromDate || '',
toDate: toDate || ''
});
const popupOptions = `width=${window.screen.availWidth},height=${window.screen.availHeight},left=0,top=0,resizable=yes,scrollbars=yes`;
popupRef.current = window.open(`/mapDrgtPopUp?${params.toString()}`, "가뭄 지도 보기 팝업", popupOptions);
}
}, [fromDate, toDate])
function onViewMapClick() {
const params = new URLSearchParams({
ctpvCd: String(objSearch.city),
sggCd: String(objSearch.sgg),
fromDate: fromDate || '',
toDate: toDate || ''
});
const popupOptions = `width=${window.screen.availWidth},height=${window.screen.availHeight},left=0,top=0,resizable=yes,scrollbars=yes`;
popupRef.current = window.open(`/mapDrgtPopUp?${params.toString()}`, "가뭄 지도 보기 팝업", popupOptions);
}
return (
<>
<ContentsDiv>
<div className="grid grid-cols-4 gap-4 ">
<div className="col-span-4">
<Title title={"우리동네가뭄"} />
<SearchDiv>
<SearchFormLeft>
<div className="col-span-10 flex flex-row">
<SelectBox
className="col-span-2 w-1/6 h-full text-2xl"
label="시도"
selectClass={""}
selectArray={code.cityCd || []}
onSelectValue={getSelCity}
value={objSearch?.city || code.cityCd[0]?.val || ""}
/>
<SelectBox
className="col-span-2 w-1/6 h-full text-2xl ml-8"
label="시군구"
selectClass={""}
selectArray={selectSgg || [{ val: '0', text: '전체' }]}
onSelectValue={getSgg}
value={selectedSgg || '0'}
/>
<div className="col-span-5 w-auto">
<div className="flex flex-row items-center">
<Label className="text-xl text-gray-600 ml-8">조회기간</Label>
<div className="flex flex-row w-1/4">
<DateMonthPicker
field={{
value: fromDate ?? "",
onChange: (date: string | undefined) => {
const year = date ? date.slice(0, 4) : '';
let month = date?.slice(4);
if (month && month?.length < 2) {
month = month?.padStart(2, "0");
}
const formatted = `${year}${month}`;
setFromDate(formatted);
}
}}
selectClass="ml-4 text-2xl"
/>
<span className="ml-2 text-center">~</span>
<DateMonthPicker
field={{
value: toDate ?? "",
onChange: (date: string | undefined) => {
const year = date ? date.slice(0, 4) : '';
let month = date?.slice(4);
if (month && month.length < 2) {
month = month?.padStart(2, "0");
}
const formatted = `${year}${month}`;
setToDate(formatted);
}
}}
selectClass="ml-4"
/>
</div>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight
className="w-2/12 mx-5 flex flex-col items-center justify-center space-x-2 ">
<div className="flex flex-row space-x-2 items-center">
<Button
size={"sm"}
onClick={() => {
let sidoText: string = '';
let sggText: string | string[] = '';
return (
<>
<ContentsDiv>
<div className="grid grid-cols-4 gap-4 ">
<div className="col-span-4">
<Title title={"우리동네가뭄"} />
<SearchDiv>
<SearchFormLeft>
<div className="col-span-10 flex flex-row">
<SelectBox
className="col-span-2 w-1/6 h-full text-2xl"
label="시도"
selectClass={""}
selectArray={code.cityCd || []}
onSelectValue={getSelCity}
value={objSearch?.city || code.cityCd[0]?.val || ""}
/>
<SelectBox
className="col-span-2 w-1/6 h-full text-2xl ml-8"
label="시군구"
selectClass={""}
selectArray={selectSgg || [{ val: '0', text: '전체' }]}
onSelectValue={getSgg}
value={selectedSgg || '0'}
/>
<div className="col-span-5 w-auto">
<div className="flex flex-row items-center">
<Label className="text-xl text-gray-600 ml-8">조회기간</Label>
<div className="flex flex-row w-1/4">
<DateMonthPicker
field={{
value: fromDate ?? "",
onChange: (date: string | undefined) => {
const year = date ? date.slice(0, 4) : '';
let month = date?.slice(4);
if (month && month?.length < 2) {
month = month?.padStart(2, "0");
}
const formatted = `${year}${month}`;
setFromDate(formatted);
}
}}
selectClass="ml-4 text-2xl"
/>
<span className="ml-2 text-center">~</span>
<DateMonthPicker
field={{
value: toDate ?? "",
onChange: (date: string | undefined) => {
const year = date ? date.slice(0, 4) : '';
let month = date?.slice(4);
if (month && month.length < 2) {
month = month?.padStart(2, "0");
}
const formatted = `${year}${month}`;
setToDate(formatted);
}
}}
selectClass="ml-4"
/>
</div>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight
className="w-2/12 mx-5 flex flex-col items-center justify-center space-x-2 ">
<div className="flex flex-row space-x-2 items-center">
<Button
size={"sm"}
onClick={() => {
let sidoText: string = '';
let sggText: string | string[] = '';
if (objSearch?.city.toString().length > 0) {
sidoText = code.cityCd.find((item) => item.val === objSearch?.city)?.text ?? '';
@@ -224,67 +225,67 @@ export default function DrgtSttn() {
sggText = sidoText;
}
const searchArr: string[] = [];
if (sidoText) searchArr.push(sidoText);
if (Array.isArray(sggText)) {
searchArr.push(...sggText);
} else if (sggText) {
searchArr.push(sggText);
}
const searchArr: string[] = [];
if (sidoText) searchArr.push(sidoText);
if (Array.isArray(sggText)) {
searchArr.push(...sggText);
} else if (sggText) {
searchArr.push(sggText);
}
drgtSearch(fromDate ?? "", toDate ?? "", searchArr)
}}
>
조회
</Button>
<Button
size={"sm"}
className={`${btnClass} green`}
onClick={excelClick}
>
다운로드
</Button>
<Button
size={"sm"}
className={btnClass}
onClick={onViewMapClick}
>
지도보기
</Button>
drgtSearch(fromDate ?? "", toDate ?? "", searchArr)
}}
>
조회
</Button>
<Button
size={"sm"}
className={`${btnClass} green`}
onClick={excelClick}
>
다운로드
</Button>
<Button
size={"sm"}
className={btnClass}
onClick={onViewMapClick}
>
지도보기
</Button>
</div>
</SearchFormRight>
</SearchDiv>
</div>
</div>
</SearchFormRight>
</SearchDiv>
</div>
</div>
<div className="grid grid-cols-6 gap-4 ">
<div className="col-span-6">
<ListDiv>
<ScrollArea className="h-[66vh]">
<UiTable
headName={undefined}
tableData={tableData || []}
headlist={drgtHeadListExport}
pageSize={14}
total={0}
cellClick={(index: number | undefined) => {
cellClick(index ?? -1);
}}
onPageChange={() => { }}
></UiTable>
</ScrollArea>
</ListDiv>
</div>
</div>
<UiModal
open={drgtModal.isOpen}
onOpenChange={() => {
setDrgtModal({ ...drgtModal, isOpen: !drgtModal.isOpen });
}}
bodyClass={"w-[50vw] max-w-none h-[80vh]"}
body={<DrgtDetailModal data={drgtModal.data as customDrgt[]} />}
/>
<Toaster position="top-center" />
</ContentsDiv>
</>
)
<div className="grid grid-cols-6 gap-4 ">
<div className="col-span-6">
<ListDiv>
<ScrollArea className="h-[66vh]">
<UiTable
headName={undefined}
tableData={tableData || []}
headlist={drgtHeadListExport}
pageSize={14}
total={0}
cellClick={(index: number | undefined) => {
cellClick(index ?? -1);
}}
onPageChange={() => { }}
></UiTable>
</ScrollArea>
</ListDiv>
</div>
</div>
<UiModal
open={drgtModal.isOpen}
onOpenChange={() => {
setDrgtModal({ ...drgtModal, isOpen: !drgtModal.isOpen });
}}
bodyClass={"w-[50vw] max-w-none h-[80vh]"}
body={<DrgtDetailModal data={drgtModal.data as customDrgt[]} />}
/>
<Toaster position="top-center" />
</ContentsDiv>
</>
)
}
+13 -3
View File
@@ -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,13 +22,21 @@ 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>
<I18nextProvider i18n={i18next}>
<StoreProvider>
{children}
</StoreProvider>
<StoreProvider>
{children}
</StoreProvider>
</I18nextProvider>
</body>
</html>
@@ -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>
);
}
-298
View File
@@ -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>
);
}
-351
View File
@@ -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>
);
}
+93
View File
@@ -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,
},
}));
},
};
};
+39
View File
@@ -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; // 재이용시설 코드
}
+2 -2
View File
@@ -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(),
+10 -2
View File
@@ -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),
};
});
};
+2
View File
@@ -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,12 +174,13 @@ 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) {
const data: DedMatUpdateType = {
@@ -178,31 +192,37 @@ export default function ConCostForm({
flg: checkFinish ? "Checked" : "Sub",
rgtr: TEST_UUID,
};
if (checkFinish) {
toast((t) => (
<span className="flex flex-col items-center text-2xl">
검토완료 변경시 공사비는 변경할수 없습니다.
<br />
진행하시겠습니까?
<div className="flex gap-2 mt-2">
<Button
size="sm"
onClick={() => {
toast.dismiss(t.id);
coMatRvwUpdate(pk, form.getValues("dmdInfoId"), data);
}}
>
확인
</Button>
<Button size="sm" onClick={() => toast.dismiss(t.id)}>
취소
</Button>
</div>
</span>
));
} else {
coMatRvwUpdate(pk, form.getValues("dmdInfoId"), data);
if(pk !==null){
if (checkFinish) {
toast((t) => (
<span className="flex flex-col items-center text-2xl">
검토완료 변경시 공사비는 변경할수 없습니다.
<br />
진행하시겠습니까?
<div className="flex gap-2 mt-2">
<Button
size="sm"
onClick={() => {
toast.dismiss(t.id);
coMatRvwUpdate(pk, form.getValues("dmdInfoId"), data);
}}
>
확인
</Button>
<Button size="sm" onClick={() => toast.dismiss(t.id)}>
취소
</Button>
</div>
</span>
));
} else {
coMatRvwUpdate(pk, form.getValues("dmdInfoId"), data);
}
}else{
toast.error('후보지를 먼저 선택해주세요')
}
}
} else if (type === "hist") {
@@ -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" />
@@ -515,6 +582,8 @@ export default function ConCostForm({
onClick={(e) => {
console.log(e);
setLastSub(null) // 빌드오류때문에 넣은거 삭제필요
searchCandidate(e);
//splyList => 해당객체가 대민에서 선택한 후보처 리스트
//setLastSub => 해당 스테이트가 선택한 마지막 후보처를 넣어야하는 스테이트
+1 -13
View File
@@ -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));
}
+4
View File
@@ -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
View File
@@ -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
+11353
View File
File diff suppressed because it is too large Load Diff