Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# apps/cs/src/shared/config/env.ts 참고
|
||||
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
|
||||
NEXT_PUBLIC_PORT=3001
|
||||
NEXT_PUBLIC_PROXY_HOST=http://localhost:3001
|
||||
NEXT_PUBLIC_SITE_ID=CM
|
||||
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000
|
||||
# NEXT_PUBLIC_SERVER_API_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
|
||||
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
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { NoLNBLayout } from "@common/components";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { authService } from "@/entities/auth";
|
||||
import { useMenuStore } from "@/shared/lib";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
|
||||
@@ -26,6 +27,7 @@ export default function WithBaseLayout({
|
||||
return (
|
||||
<NoLNBLayout
|
||||
queryClient={queryClient}
|
||||
authService={authService}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,10 @@ async function handler(
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
const deviceId = request.headers.get("X-Device-Id");
|
||||
if (deviceId) {
|
||||
headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
@@ -57,40 +61,57 @@ async function handler(
|
||||
});
|
||||
|
||||
console.log(`[Proxy] Response status: ${result.status}`);
|
||||
console.log(`[Proxy] Response headers:`, Object.fromEntries(result.headers.entries()));
|
||||
console.log(
|
||||
`[Proxy] Response headers:`,
|
||||
Object.fromEntries(result.headers.entries()),
|
||||
);
|
||||
|
||||
// 2FA 관련 엔드포인트인 경우 응답 본문도 확인
|
||||
const is2FAEndpoint = params.url.includes("2fa") || params.url.includes("auth");
|
||||
const is2FAEndpoint =
|
||||
params.url.includes("2fa") || params.url.includes("auth");
|
||||
// 배너 API는 인증 없이 호출 가능
|
||||
const isBannerEndpoint = params.url.includes("bnrs");
|
||||
let responseData = {};
|
||||
|
||||
|
||||
if (is2FAEndpoint || isBannerEndpoint) {
|
||||
try {
|
||||
responseData = await result.json();
|
||||
console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} Response data:`, responseData);
|
||||
console.log(
|
||||
`[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} Response data:`,
|
||||
responseData,
|
||||
);
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 빈 객체 사용
|
||||
console.log(`[Proxy] Failed to parse JSON response`);
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = result.headers.get(ACCESS_TOKEN) || result.headers.get('ss-token');
|
||||
const accessToken =
|
||||
result.headers.get(ACCESS_TOKEN) || result.headers.get("ss-token");
|
||||
const refreshToken = result.headers.get(REFRESH_TOKEN);
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
console.log(`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`);
|
||||
console.log(`[Proxy] Available headers:`, Array.from(result.headers.keys()));
|
||||
console.log(
|
||||
`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`,
|
||||
);
|
||||
console.log(
|
||||
`[Proxy] Available headers:`,
|
||||
Array.from(result.headers.keys()),
|
||||
);
|
||||
|
||||
// 2FA 또는 배너 엔드포인트이고 실패한 경우 실제 오류 메시지 반환
|
||||
if (!result.ok && (is2FAEndpoint || isBannerEndpoint)) {
|
||||
console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint failed, returning actual error`);
|
||||
console.log(
|
||||
`[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} endpoint failed, returning actual error`,
|
||||
);
|
||||
return NextResponse.json(responseData, { status: result.status });
|
||||
}
|
||||
|
||||
// 2FA 또는 배너 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음)
|
||||
if (result.ok && (is2FAEndpoint || isBannerEndpoint)) {
|
||||
console.log(`[Proxy] ${is2FAEndpoint ? '2FA' : 'Banner'} endpoint succeeded, returning response data`);
|
||||
console.log(
|
||||
`[Proxy] ${is2FAEndpoint ? "2FA" : "Banner"} endpoint succeeded, returning response data`,
|
||||
);
|
||||
return NextResponse.json(responseData, { status: result.status });
|
||||
}
|
||||
|
||||
|
||||
+15
-19
@@ -8,20 +8,16 @@ import {
|
||||
SITE_ID,
|
||||
} from "./src/shared/config";
|
||||
|
||||
const serverApiUrl =
|
||||
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
|
||||
const serverApiUrl = process.env.SERVER_API_URL;
|
||||
|
||||
const serverApiUrl2 =
|
||||
process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl;
|
||||
const serverApiUrl2 = process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl;
|
||||
|
||||
const serverApiUrl3 =
|
||||
process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
|
||||
const serverApiUrl3 = process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
|
||||
|
||||
// const serverApiUrl4 =
|
||||
// process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
|
||||
// const serverApiUrl4 =
|
||||
// process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
|
||||
|
||||
const serverApiUrl5 =
|
||||
process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl;
|
||||
const serverApiUrl5 = process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl;
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
@@ -51,7 +47,6 @@ export function middleware(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (pathname.startsWith("/server/sewerage-service")) {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
const token = req.cookies.get(CLAIM_NAME)?.value;
|
||||
@@ -173,7 +168,6 @@ export function middleware(req: NextRequest) {
|
||||
return NextResponse.rewrite(new URL(newUrl));
|
||||
}
|
||||
|
||||
|
||||
if (pathname === "/") {
|
||||
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
}
|
||||
@@ -184,11 +178,13 @@ export function middleware(req: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/server/:path*",
|
||||
"/attach-service/:path*",
|
||||
"/user-service/:path*",
|
||||
"/sewerage-service/:path*",
|
||||
"/reuse-service/:path*",
|
||||
"/operation-service/:path*",
|
||||
"/"],
|
||||
matcher: [
|
||||
"/server/:path*",
|
||||
"/attach-service/:path*",
|
||||
"/user-service/:path*",
|
||||
"/sewerage-service/:path*",
|
||||
"/reuse-service/:path*",
|
||||
"/operation-service/:path*",
|
||||
"/",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3002",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/config": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -46,6 +47,7 @@
|
||||
"sonner": "^2.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
} from "@/shared/config";
|
||||
import {
|
||||
type AuthPayload,
|
||||
type AuthService,
|
||||
getRemainingMs,
|
||||
} from "@common/auth";
|
||||
|
||||
import {
|
||||
clearRefreshTimer,
|
||||
setToken,
|
||||
setupRefreshTimer,
|
||||
tokenManager,
|
||||
} from "../lib/token-manager";
|
||||
import { clearDeviceId } from "../lib/device-id";
|
||||
|
||||
function resolveLoginError(message: string | undefined): string {
|
||||
switch (message) {
|
||||
case "Invalid Credentials":
|
||||
return "아이디와 비밀번호가 일치하지 않습니다.";
|
||||
case "User not found":
|
||||
return "존재하지 않는 아이디입니다.";
|
||||
case "User is not active":
|
||||
return "활성화되지 않은 계정입니다.";
|
||||
case "User is not verified":
|
||||
return "이메일 인증이 필요합니다.";
|
||||
default:
|
||||
return message ?? "로그인에 실패했습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
function completeSession(accessToken: string, tokenId: string): void {
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveLoginError(data?.message));
|
||||
}
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN] as string | undefined;
|
||||
const tokenId = data[AUTH_USER_ID] as string | undefined;
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
completeSession(accessToken, tokenId);
|
||||
}
|
||||
|
||||
async function silentRefresh(): Promise<void> {
|
||||
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
|
||||
{ method: "PUT" },
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
|
||||
}
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN] as string | undefined;
|
||||
const tokenId = data[AUTH_USER_ID] as string | undefined;
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
completeSession(accessToken, tokenId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[silentRefresh] Failed at ${new Date().toISOString()}`,
|
||||
error,
|
||||
);
|
||||
tokenManager.clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
tokenManager.clearAuthToken();
|
||||
clearDeviceId();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
|
||||
export const authService: AuthService & {
|
||||
completeSession: typeof completeSession;
|
||||
} = {
|
||||
login,
|
||||
silentRefresh,
|
||||
declineSessionExtend: clearRefreshTimer,
|
||||
logout,
|
||||
completeSession,
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
import {
|
||||
ENDPOINT,
|
||||
JWT_EXPIRATION_TIME,
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
} from "@/shared/config";
|
||||
import type { LoginService, ILogin } from "../model/login-interface";
|
||||
import { tokenManager } from "../lib/token-manager";
|
||||
|
||||
export class LoginApiService implements LoginService {
|
||||
private static instance: LoginApiService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LoginApiService {
|
||||
if (!LoginApiService.instance) {
|
||||
LoginApiService.instance = new LoginApiService();
|
||||
}
|
||||
return LoginApiService.instance;
|
||||
}
|
||||
|
||||
public async login(payload: ILogin): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
password: payload.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
tokenManager.setupRefreshTimer(
|
||||
() => this.silentRefresh(),
|
||||
JWT_EXPIRATION_TIME,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new Error(`Login failed: ${error.response?.data?.message}`);
|
||||
}
|
||||
throw new Error(`Login failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async silentRefresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
|
||||
{ method: "PUT" },
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
} catch (error) {
|
||||
tokenManager.clearAuthToken();
|
||||
if (error instanceof AxiosError) {
|
||||
throw new Error(
|
||||
`Silent refresh failed: ${error.response?.data?.message}`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Silent refresh failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoginApi = LoginApiService.getInstance();
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { authService } from "./api/auth-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
export { tokenManager } from "./lib/token-manager";
|
||||
|
||||
@@ -2,47 +2,77 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { client } from "@/shared/api";
|
||||
import {
|
||||
AUTH_LOGIN_REDIRECT_URL,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
} from "@/shared/config";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
import { getCookie, useUserStore } from "@/shared/lib";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { isAtLoginRedirectUrl } from "@common/network";
|
||||
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
|
||||
|
||||
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
import { authService } from "../api/auth-service";
|
||||
import { getToken, setupRefreshTimer } from "./token-manager";
|
||||
|
||||
export function AuthWrapper({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { userInfo, setUserInfo } = useUserStore((state) => state);
|
||||
|
||||
async function authCheck() {
|
||||
if (isAtLoginRedirectUrl("/auth/login")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = getCookie(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
const response = await client.get<IUserInfo>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
|
||||
);
|
||||
if (!userInfo) setUserInfo(response.data);
|
||||
}
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const setUserInfo = useUserStore((state) => state.setUserInfo);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await authCheck();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl("/auth/login")) {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
const token = getToken(CLAIM_NAME);
|
||||
if (token) {
|
||||
const remainingMs = getRemainingMs(token);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(authService.silentRefresh, remainingMs);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
|
||||
|
||||
async function authCheck() {
|
||||
const userId = getToken(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userInfo) return;
|
||||
|
||||
const response = await client.get<IUserInfo>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
|
||||
);
|
||||
setUserInfo(response.data);
|
||||
}
|
||||
|
||||
authCheck().catch((error) => {
|
||||
if (isAxiosError(error) && error.response?.status === 401) return;
|
||||
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthLogout() {
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("auth:logout", handleAuthLogout);
|
||||
return () => window.removeEventListener("auth:logout", handleAuthLogout);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export { AuthWrapper };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const DEVICE_ID_KEY = "x-device-id";
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
|
||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
const id = uuidv4();
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function clearDeviceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
localStorage.removeItem(DEVICE_ID_KEY);
|
||||
}
|
||||
@@ -1,84 +1,15 @@
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, REFRESH_TOKEN } from "@/shared/config";
|
||||
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
|
||||
import { authConfig } from "@/shared/config/auth";
|
||||
import { createTokenManager } from "@common/auth";
|
||||
|
||||
export class TokenManager {
|
||||
private static instance: TokenManager;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
public getToken(key: string): string | undefined {
|
||||
return getCookie(key);
|
||||
}
|
||||
|
||||
public setToken(key: string, value: string): void {
|
||||
// 환경에 따른 도메인 설정
|
||||
const domain = this.getCookieDomain();
|
||||
|
||||
setCookie(key, value, {
|
||||
secure: window.location.protocol === "https:",
|
||||
sameSite: "lax",
|
||||
domain: domain,
|
||||
path: "/", // 모든 경로에서 접근 가능
|
||||
});
|
||||
}
|
||||
|
||||
private getCookieDomain(): string | undefined {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 개발 환경에서는 localhost 도메인 사용
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
return undefined; // localhost에서는 도메인 설정하지 않음
|
||||
}
|
||||
|
||||
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
|
||||
if (hostname.includes("hasuon.or.kr")) {
|
||||
return ".hasuon.or.kr";
|
||||
}
|
||||
|
||||
// 기타 환경에서는 현재 호스트명 사용
|
||||
return hostname;
|
||||
}
|
||||
|
||||
public removeToken(key: string): void {
|
||||
const domain = this.getCookieDomain();
|
||||
removeCookie(key, { domain, path: "/" });
|
||||
}
|
||||
|
||||
public setupRefreshTimer(
|
||||
callback: () => Promise<void>,
|
||||
expirationTime: number,
|
||||
): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
callback().catch((error) => {
|
||||
console.error("Silent refresh failed:", error);
|
||||
this.clearAuthToken();
|
||||
});
|
||||
}, expirationTime - 60000);
|
||||
}
|
||||
|
||||
public clearAuthToken(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.removeToken(CLAIM_NAME);
|
||||
this.removeToken(AUTH_USER_ID);
|
||||
this.removeToken(REFRESH_TOKEN);
|
||||
this.removeToken(ACCESS_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearRefreshTimer,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ILogin {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginService {
|
||||
login(payload: ILogin): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/shared/config";
|
||||
import { tokenManager } from "@/entities/auth/lib/token-manager";
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, ENDPOINT } from "@/shared/config";
|
||||
import { authService } from "@/entities/auth";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -33,7 +33,7 @@ export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: Otp
|
||||
}
|
||||
|
||||
// Proxy를 통해 OTP 인증 처리
|
||||
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.OTP_VERIFY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -57,37 +57,19 @@ export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: Otp
|
||||
console.log("Response data keys:", Object.keys(data));
|
||||
console.log("Full response data:", JSON.stringify(data, null, 2));
|
||||
|
||||
// 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음
|
||||
const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token'];
|
||||
|
||||
console.log("Extracted accessToken:", !!accessToken);
|
||||
console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "...");
|
||||
|
||||
// access-token이 있는 경우 저장
|
||||
if (accessToken) {
|
||||
console.log("Saving access token from OTP verification");
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장
|
||||
|
||||
// 리프레시 토큰도 확인
|
||||
const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token;
|
||||
if (refreshToken) {
|
||||
console.log("Saving refresh token");
|
||||
tokenManager.setToken(REFRESH_TOKEN, refreshToken);
|
||||
}
|
||||
|
||||
// userId는 원래 로그인 응답에서 가져온 값 사용
|
||||
const originalUserId = data.userId;
|
||||
if (originalUserId) {
|
||||
console.log("Saving userId from original login response");
|
||||
tokenManager.setToken(AUTH_USER_ID, originalUserId);
|
||||
}
|
||||
|
||||
console.log("All tokens saved successfully");
|
||||
} else {
|
||||
console.log("No access token in OTP response");
|
||||
console.log("Available data keys:", Object.keys(data));
|
||||
console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`));
|
||||
const accessToken =
|
||||
data[ACCESS_TOKEN] ||
|
||||
data.accessToken ||
|
||||
data.access_token ||
|
||||
data.token ||
|
||||
data["ss-token"];
|
||||
const tokenId = data[AUTH_USER_ID] || data.userId;
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
authService.completeSession(accessToken, tokenId);
|
||||
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -882,11 +882,6 @@ function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
|
||||
type="submit"
|
||||
className="user-signup-submit-button"
|
||||
disabled={isLoading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
console.log("회원가입 버튼 클릭됨 - onClick");
|
||||
handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
{isLoading ? "가입 중..." : "회원가입"}
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { images } from "@common/assets";
|
||||
import { LoginApi } from "@/entities/auth";
|
||||
import { authService } from "@/entities/auth";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import OtpModal from "./OtpModal";
|
||||
import TwoFAModal from "./TwoFAModal";
|
||||
@@ -96,7 +96,7 @@ function LoginPage() {
|
||||
} else {
|
||||
// 기본 로그인 - proxy를 통해 처리
|
||||
console.log("Starting basic login...");
|
||||
await LoginApi.login({ userId, password });
|
||||
await authService.login({ userId, password });
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { tokenManager } from "@/entities/auth/lib/token-manager";
|
||||
import { authService, tokenManager } from "@/entities/auth";
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { KoreaBasinMap } from "../lib/korea-basin-map";
|
||||
@@ -120,13 +120,11 @@ function DashboardPage() {
|
||||
fetchUserInfo();
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
console.log("Logging out...");
|
||||
// localStorage 정리
|
||||
localStorage.removeItem("tempToken");
|
||||
// tokenManager를 통해 쿠키 정리
|
||||
tokenManager.clearAuthToken();
|
||||
router.push("/auth/logout");
|
||||
await authService.logout();
|
||||
router.push("/auth/login");
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
|
||||
@@ -97,4 +97,3 @@ export const ENDPOINT = {
|
||||
|
||||
};
|
||||
|
||||
export const JWT_EXPIRATION_TIME = 1800000;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AuthConfig } from "@common/auth";
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
COOKIE_DOMAIN,
|
||||
REFRESH_TOKEN,
|
||||
} from "./env";
|
||||
|
||||
export const authConfig: AuthConfig = {
|
||||
authUserId: AUTH_USER_ID,
|
||||
claimName: CLAIM_NAME,
|
||||
cookieDomain: COOKIE_DOMAIN,
|
||||
accessToken: ACCESS_TOKEN,
|
||||
refreshToken: REFRESH_TOKEN,
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
|
||||
export const SERVER_API_URL = process.env.SERVER_API_URL;
|
||||
export const AUTH_LOGIN_REDIRECT_URL =
|
||||
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN =
|
||||
@@ -9,3 +11,4 @@ export const ACCESS_TOKEN =
|
||||
export const REFRESH_TOKEN =
|
||||
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./auth";
|
||||
export * from "./env";
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
REFRESH_TOKEN,
|
||||
} from "@/shared/config";
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(ACCESS_TOKEN);
|
||||
cookieStore.delete(REFRESH_TOKEN);
|
||||
cookieStore.delete(AUTH_USER_ID);
|
||||
cookieStore.delete(CLAIM_NAME);
|
||||
return NextResponse.json({ message: "Logged out" }, { status: 200 });
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
@@ -7,8 +10,6 @@ import {
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
} from "@/shared/config";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
async function handler(
|
||||
request: Request,
|
||||
@@ -20,6 +21,10 @@ async function handler(
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
const deviceId = request.headers.get("X-Device-Id");
|
||||
if (deviceId) {
|
||||
headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/config": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.5",
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
"@fullcalendar/core": "^6.1.17",
|
||||
@@ -44,6 +45,7 @@
|
||||
"sonner": "^2.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_LOGIN_REDIRECT_URL,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
} from "@/shared/config";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
|
||||
import {
|
||||
removeToken,
|
||||
type AuthPayload,
|
||||
type AuthService,
|
||||
getRemainingMs,
|
||||
} from "@common/auth";
|
||||
|
||||
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
|
||||
import {
|
||||
clearRefreshTimer,
|
||||
setToken,
|
||||
setupRefreshTimer,
|
||||
tokenManager,
|
||||
} from "../lib/token-manager";
|
||||
import type { AuthPayload, AuthService } from "../model/auth-interface";
|
||||
|
||||
function resolveLoginError(message: string | undefined): string {
|
||||
switch (message) {
|
||||
@@ -33,7 +36,10 @@ function resolveLoginError(message: string | undefined): string {
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
@@ -64,7 +70,12 @@ async function silentRefresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
|
||||
{ method: "PUT" },
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
@@ -101,27 +112,17 @@ async function silentRefresh(): Promise<void> {
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Logout API failed:", error);
|
||||
}
|
||||
|
||||
tokenManager.clearAuthToken();
|
||||
removeToken("site-id-key");
|
||||
removeToken("site-theme-key");
|
||||
clearDeviceId();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
window.location.href = AUTH_LOGIN_REDIRECT_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export const authService: AuthService = {
|
||||
login,
|
||||
silentRefresh,
|
||||
declineSessionExtend: clearRefreshTimer,
|
||||
logout,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const DEVICE_ID_KEY = "x-device-id";
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
|
||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
const id = uuidv4();
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function clearDeviceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
localStorage.removeItem(DEVICE_ID_KEY);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const {
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearRefreshTimer,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type { AuthPayload, AuthService } from "@common/auth";
|
||||
@@ -22,12 +22,8 @@
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@common/components": ["../../../packages/common/components/src/"],
|
||||
"@common/config": ["../../../packages/common/config/dist/"],
|
||||
"@common/network": ["../../../packages/common/network/"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
+7
-7
@@ -6,18 +6,18 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://localhost:3015
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
# NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
|
||||
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service
|
||||
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_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # local
|
||||
SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
# GATEWAY_URL=http://localhost:8883 # local
|
||||
CONTENTS_INTRO = 31
|
||||
CONTENTS_EXAMPLE = 32
|
||||
|
||||
|
||||
+8
-11
@@ -6,20 +6,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
#NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
#NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
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_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
|
||||
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
|
||||
SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
CONTENTS_INTRO = 31
|
||||
CONTENTS_EXAMPLE = 32
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
#NEXT_PUBLIC_PROXY_HOST = https://devreuse.hasuon.or.kr
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
|
||||
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
|
||||
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
|
||||
WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
|
||||
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_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
|
||||
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
|
||||
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
|
||||
NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
|
||||
GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
CONTENTS_INTRO = 24
|
||||
CONTENTS_EXAMPLE = 25
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { sha512 } from "js-sha512";
|
||||
import SubPageHeader from "@/components/SubPageHeader";
|
||||
import PostcodeModal from "@/components/PostcodeModal";
|
||||
import type { AddrItem } from "@/app/store/sdmMng/postcode-service";
|
||||
import { useDmStore } from "@/app/store";
|
||||
import { fetchType, getFetch, postFetch, putFetch } from "@/components/ui/api";
|
||||
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
|
||||
import { getCookie } from "@common/components/lib";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
@@ -165,7 +167,7 @@ function Hr({ className = "" }: { className?: string }) {
|
||||
|
||||
export default function DemandPlaceRegisterPage() {
|
||||
const router = useRouter();
|
||||
const { Common, initializeCommonData } = useDmStore((state) => state);
|
||||
const { Common, initializeCommonData, actionLogin } = useDmStore((state) => state);
|
||||
const { selectSidoData, selectSigunData } = Common;
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -424,7 +426,7 @@ export default function DemandPlaceRegisterPage() {
|
||||
};
|
||||
|
||||
if (password) {
|
||||
payload.dmdAplyPswd = password;
|
||||
payload.dmdAplyPswd = sha512.hex(password);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -443,21 +445,20 @@ export default function DemandPlaceRegisterPage() {
|
||||
|
||||
alert("수요처 수정 완료!");
|
||||
} else {
|
||||
const response = await postFetch("/reuseFc/request", payload);
|
||||
const responseJson = await response.json().catch(() => null);
|
||||
const savedUser = responseJson?.data ?? payload;
|
||||
await postFetch("/reuseFc/request", payload);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const nextSessionUser = {
|
||||
...(sessionUser ?? {}),
|
||||
...savedUser,
|
||||
};
|
||||
delete nextSessionUser.dmdAplyPswd;
|
||||
sessionStorage.setItem("user", JSON.stringify(nextSessionUser));
|
||||
const loginSuccess = await actionLogin({
|
||||
dmdInfoId: fullDmdInfoId,
|
||||
dmdAplyPswd: sha512.hex(password),
|
||||
} as DemandCompType);
|
||||
|
||||
if (!loginSuccess) {
|
||||
alert("수요처 등록은 완료되었지만 자동 로그인에 실패하였습니다. 다시 로그인해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
alert("수요처 등록 완료!");
|
||||
router.push("/apply/dmdinfo"); // 수요정보등록
|
||||
router.push("/apply/dmdinfo");
|
||||
}
|
||||
|
||||
setPw("");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, createContext, useContext, useRef } from 'react'
|
||||
import { type ReactNode, createContext, useContext, useEffect, useRef } from 'react'
|
||||
import { useStore } from 'zustand'
|
||||
import { type DmStore, StoreZus } from './store'
|
||||
|
||||
@@ -23,6 +23,11 @@ export const StoreProvider = ({
|
||||
// Create store instance if not initialized
|
||||
storeRef.current = StoreZus() as StoreApi
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
storeRef.current?.getState().hydrateLoginUser();
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
@@ -39,4 +44,4 @@ export const useDmStore = <T,>(
|
||||
}
|
||||
|
||||
return useStore(storeContext, selector)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,48 @@ export const sdmInfoFormSchema = z.object({
|
||||
|
||||
export type SdmInfoFormData = z.infer<typeof sdmInfoFormSchema>;
|
||||
|
||||
export type LoginUser = {
|
||||
dmdCoNm?: string;
|
||||
dmdInfoId?: string;
|
||||
dmdCoSn?: number;
|
||||
zip?: string;
|
||||
daddr?: string;
|
||||
daddr2?: string;
|
||||
telno?: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
rgtr?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const formatTelno = (telno?: string | null) => {
|
||||
const digits = (telno ?? '').replace(/\D/g, '');
|
||||
|
||||
if (!digits) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (digits.startsWith('02')) {
|
||||
if (digits.length === 9) {
|
||||
return digits.replace(/(02)(\d{3})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return digits.replace(/(02)(\d{4})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
}
|
||||
|
||||
if (digits.length === 10) {
|
||||
return digits.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
|
||||
if (digits.length === 11) {
|
||||
return digits.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
|
||||
}
|
||||
|
||||
return telno ?? '';
|
||||
};
|
||||
|
||||
|
||||
// 수요처 정보
|
||||
export type DemandCompBizType = {
|
||||
@@ -484,7 +526,7 @@ const fetchUpdateSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> =
|
||||
}
|
||||
}
|
||||
|
||||
const postSdmLoginData = async (data: DemandCompType) => {
|
||||
const postSdmLoginData = async (data: DemandCompType): Promise<boolean> => {
|
||||
try {
|
||||
const res = await postFetch("/reuseFc/request/login", data);
|
||||
const jsonData = await res.json();
|
||||
@@ -511,9 +553,23 @@ const postSdmLoginData = async (data: DemandCompType) => {
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
alert("수요처 로그인에 실패하였습니다.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const getStoredLoginUser = (): LoginUser | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const sessionUser = sessionStorage.getItem("user");
|
||||
if (!sessionUser) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(sessionUser) as LoginUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getDemandCompList = async (searchText: string, pageCnt: number) => {
|
||||
try {
|
||||
const url = '/reuseFc/request/' + (searchText ? searchText : '') + '/' + pageCnt;
|
||||
@@ -530,7 +586,7 @@ const getDemandCompList = async (searchText: string, pageCnt: number) => {
|
||||
|
||||
const getSdmInfoStatusList = async (dmdInfoId: string) => {
|
||||
try {
|
||||
const url = '/reuseFc/request/' + dmdInfoId;
|
||||
const url = '/reuseFc/request/' + dmdInfoId + '?infoListYn=Y';
|
||||
const res = await getFetch(fetchType.WR, url);
|
||||
return res?.data || [];
|
||||
} catch (error) {
|
||||
@@ -558,11 +614,12 @@ const fetchGetSimilarCaseList = async (part: string, value: number | string) =>
|
||||
====================================*/
|
||||
export type SdmActions = {
|
||||
initializeSdmData: () => void;
|
||||
hydrateLoginUser: () => void;
|
||||
insertSdmData: (data: DemandCompType) => Promise<void>;
|
||||
insertSdmInfoData: (data: SdmInfoFormData) => Promise<boolean>;
|
||||
updateSdmInfoData: (data: SdmInfoFormData) => Promise<boolean>;
|
||||
getDemandCompList: (searchText: string, pageCnt: number) => Promise<void>;
|
||||
actionLogin: (data: DemandCompType) => Promise<void>;
|
||||
actionLogin: (data: DemandCompType) => Promise<boolean>;
|
||||
getSdmInfoStatusList: (dmdInfoId: string) => Promise<void>;
|
||||
getSimilarCaseList: (part: string, value: number | string) => Promise<void>;
|
||||
actionLogout: () => Promise<void>;
|
||||
@@ -588,6 +645,7 @@ export type SdmType = {
|
||||
// 로그인관련
|
||||
successNextPage: boolean;
|
||||
successLogin: boolean;
|
||||
loginUser: LoginUser | null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,6 +667,7 @@ export const SdmState: SdmType = {
|
||||
// 로그인관련
|
||||
successNextPage: false,
|
||||
successLogin: false,
|
||||
loginUser: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +675,15 @@ export const SdmReducer = (set: any): SdmActions => {
|
||||
return {
|
||||
initializeSdmData: async () => {
|
||||
},
|
||||
hydrateLoginUser: () => {
|
||||
const loginUser = getStoredLoginUser();
|
||||
set((state: SdmType) => ({
|
||||
Sdm: {
|
||||
...state.Sdm,
|
||||
loginUser,
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
insertSdmData: async (data: DemandCompType) => {
|
||||
const preRes = await getSdmInfoStatusList(data.dmdInfoId);
|
||||
@@ -694,7 +762,7 @@ export const SdmReducer = (set: any): SdmActions => {
|
||||
dmdInfoId: res.dmdInfoId,
|
||||
zip: res.zip || '',
|
||||
daddr: res.daddr,
|
||||
telno: res.telno.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'),
|
||||
telno: formatTelno(res.telno),
|
||||
rgtr: res.rgtr,
|
||||
ctpvCd: res.ctpvCd,
|
||||
sggCd: res.sggCd,
|
||||
@@ -715,14 +783,20 @@ export const SdmReducer = (set: any): SdmActions => {
|
||||
}))
|
||||
},
|
||||
|
||||
actionLogin: async (data: DemandCompType) => {
|
||||
actionLogin: async (data: DemandCompType): Promise<boolean> => {
|
||||
const loginSuccess = await postSdmLoginData(data);
|
||||
const loginUser = loginSuccess ? getStoredLoginUser() : undefined;
|
||||
set((state: SdmType) => ({
|
||||
Sdm: {
|
||||
...state.Sdm,
|
||||
successLogin: loginSuccess,
|
||||
loginUser:
|
||||
loginSuccess
|
||||
? loginUser
|
||||
: state.Sdm.loginUser,
|
||||
}
|
||||
}))
|
||||
return loginSuccess;
|
||||
},
|
||||
|
||||
// 유사사례 조회
|
||||
@@ -764,6 +838,7 @@ export const SdmReducer = (set: any): SdmActions => {
|
||||
Sdm: {
|
||||
...state.Sdm,
|
||||
successLogin: false,
|
||||
loginUser: null,
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -8,9 +8,6 @@ import LoginModal from "@/components/LoginModal";
|
||||
import { fetchAnonymousMenu, isMenuActive, type MenuItem } from "@/app/store/common";
|
||||
import { useDmStore } from "@/app/store";
|
||||
|
||||
// const LS_TOKEN_KEY = "reuse_accessToken";
|
||||
const LS_USER_KEY = "user";
|
||||
|
||||
const DEFAULT_NAV_ITEMS: Pick<MenuItem, "menuCd" | "menuKornNm" | "urlPath" | "newWndwYn">[] = [
|
||||
{ menuCd: 1, menuKornNm: "물 재이용 소개", urlPath: "/intro", newWndwYn: "N" },
|
||||
{ menuCd: 2, menuKornNm: "물 재이용 현황", urlPath: "/status", newWndwYn: "N" },
|
||||
@@ -22,21 +19,11 @@ export default function TopNav() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const { actionLogout } = useDmStore((state) => state);
|
||||
const { actionLogout, Sdm } = useDmStore((state) => state);
|
||||
const { loginUser } = Sdm;
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [topMenus, setTopMenus] = useState<MenuItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sessionUser = sessionStorage.getItem(LS_USER_KEY);
|
||||
if (sessionUser) {
|
||||
const parsed = JSON.parse(sessionUser) as { dmdCoNm?: string };
|
||||
setUserName(parsed.dmdCoNm ?? "수요처");
|
||||
} else {
|
||||
setUserName(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -72,13 +59,13 @@ export default function TopNav() {
|
||||
otptYn: "Y",
|
||||
}));
|
||||
}, [topMenus]);
|
||||
|
||||
const userName = loginUser?.dmdCoNm ?? null;
|
||||
const isLoggedIn = Boolean(loginUser);
|
||||
|
||||
const openLogin = () => setLoginOpen(true);
|
||||
|
||||
const logout = async () => {
|
||||
// localStorage.removeItem(LS_TOKEN_KEY);
|
||||
// localStorage.removeItem(LS_USER_KEY);
|
||||
setUserName(null);
|
||||
await actionLogout();
|
||||
router.push("/main");
|
||||
};
|
||||
@@ -113,7 +100,7 @@ export default function TopNav() {
|
||||
active ? "text-sky-700" : "text-gray-800 hover:text-sky-700",
|
||||
].join(" ");
|
||||
|
||||
if (requiresLogin && !userName) {
|
||||
if (requiresLogin && !isLoggedIn) {
|
||||
return (
|
||||
<button
|
||||
key={item.menuCd}
|
||||
@@ -149,7 +136,7 @@ export default function TopNav() {
|
||||
|
||||
{/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{!userName ? (
|
||||
{!isLoggedIn ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLogin}
|
||||
@@ -197,10 +184,7 @@ export default function TopNav() {
|
||||
<LoginModal
|
||||
open={loginOpen}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
onSuccess={({ userName: nextUserName }) => {
|
||||
// localStorage.setItem(LS_USER_KEY, nextUserName);
|
||||
// if (accessToken) localStorage.setItem(LS_TOKEN_KEY, accessToken);
|
||||
setUserName(nextUserName);
|
||||
onSuccess={() => {
|
||||
setLoginOpen(false);
|
||||
router.refresh();
|
||||
}}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { CLAIM_NAME, AUTH_USER_ID } from "./src/shared/config";
|
||||
// import { SERVER_API_FO_URL, SERVER_API_WR_URL } from "./src/shared/config/env";
|
||||
import { SERVER_API_FO_URL, SERVER_API_WR_URL } from "./src/shared/config/env";
|
||||
|
||||
// const CLAIM_NAME = "Authorization";
|
||||
// const AUTH_USER_ID = "token-id";
|
||||
const serverApiUrl = process.env.NEXT_PUBLIC_CS_SERVER_API_URL;
|
||||
const serverApiUrl = process.env.CS_SERVER_API_URL;
|
||||
const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || "";
|
||||
const LX_GEOSERVER_URL = process.env.NEXT_PUBLIC_LX_GEOSERVER_URL || "";
|
||||
const GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL || "";
|
||||
// const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
|
||||
const GATEWAY_URL = process.env.GATEWAY_URL || "";
|
||||
// const SEWERAGE_API_URL = process.env.SEWERAGE_API_URL || "";
|
||||
const BUSINESS_JUSO_API_URL = process.env.NEXT_PUBLIC_FINDADRESS_URL || "";
|
||||
const AdressKey = process.env.NEXT_PRIVATE_WR_ADDRESS_KEY || "";
|
||||
|
||||
@@ -117,12 +117,12 @@ export function middleware(req: NextRequest) {
|
||||
|
||||
if (pathname.startsWith("/reuse-service")) {
|
||||
const queryParams = searchParams.toString();
|
||||
const newUrl = `${GATEWAY_URL}${pathname}${queryParams ? `?${queryParams}` : ""}`;
|
||||
const newUrl = `${SERVER_API_WR_URL}${pathname.replace("/reuse-service", "")}${queryParams ? `?${queryParams}` : ""}`;
|
||||
console.log(`[Middleware] Rewriting from '/reuse-service' path to: ${newUrl}`);
|
||||
return NextResponse.rewrite(new URL(newUrl));
|
||||
} else if (pathname.startsWith("/operation-service")) {
|
||||
const queryParams = searchParams.toString();
|
||||
const newUrl = `${GATEWAY_URL}${pathname}${queryParams ? `?${queryParams}` : ""}`;
|
||||
const newUrl = `${SERVER_API_FO_URL}${pathname.replace("/operation-service", "")}${queryParams ? `?${queryParams}` : ""}`;
|
||||
console.log(`[Middleware] Rewriting from '/operation-service' path to: ${newUrl}`);
|
||||
return NextResponse.rewrite(new URL(newUrl));
|
||||
} else if (pathname.startsWith("/sewerage-service")) {
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev -p 3015",
|
||||
"build": "next build",
|
||||
"start:dm": "next start -p 3015",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"dev:e2e": "next dev --port 3016",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
@@ -27,6 +30,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/webpack": "^5.28.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.8",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "DM";
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_DM_URL = process.env.NEXT_PUBLIC_DM_SERVER_API_URL ||'http://con.mind-one.co.kr:11035/api/v1';
|
||||
export const SERVER_API_FO_URL = process.env.NEXT_PUBLIC_FO_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
|
||||
export const SERVER_API_WR_URL = process.env.NEXT_PUBLIC_WR_SERVER_API_URL||' http://con.mind-one.co.kr:11080/api/v1';
|
||||
export const SERVER_API_CS_URL = process.env.NEXT_PUBLIC_CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
|
||||
export const SERVER_API_FO_URL = process.env.FO_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
|
||||
export const SERVER_API_WR_URL = process.env.WR_SERVER_API_URL||' http://con.mind-one.co.kr:11080/api/v1';
|
||||
export const SERVER_API_CS_URL = process.env.CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
|
||||
export const SERVER_API_CS_TUS_URL = process.env.NEXT_PUBLIC_CS_TUS_UPLOAD ||'/attach-service/api/v1/tus/upload'
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
export const SERVER_SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL;
|
||||
export const SERVER_SEWERAGE_API_URL = process.env.SEWERAGE_API_URL;
|
||||
+6
-5
@@ -7,13 +7,14 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
|
||||
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
|
||||
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
|
||||
@@ -7,13 +7,14 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11300
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
|
||||
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
|
||||
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
|
||||
@@ -7,14 +7,16 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
#NEXT_PUBLIC_PROXY_HOST = https://devlarva.hasuon.or.kr
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service
|
||||
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
|
||||
FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
|
||||
WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
|
||||
CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
|
||||
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 = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-service
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
|
||||
DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-service
|
||||
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
|
||||
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
|
||||
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=.hasuon.or.kr
|
||||
@@ -0,0 +1,17 @@
|
||||
import "@/src/app/styles/base.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "유역하수도 통합 정보플랫폼",
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LoginPage } from "@/src/auth/login";
|
||||
export default LoginPage;
|
||||
@@ -1,111 +1,124 @@
|
||||
/**
|
||||
* 📄 Proxy Route
|
||||
* API 프록시 라우트
|
||||
*
|
||||
* - 서버 API로의 요청 프록시 처리
|
||||
* - 쿠키 기반 인증 토큰 및 리프레시 토큰 관리
|
||||
* - POST 및 PUT 요청 핸들링
|
||||
* - 사용자 인증 정보 검증 및 응답 처리
|
||||
* - 에러 핸들링 및 상태 코드 반환
|
||||
* - 사이트 ID 및 헤더 설정
|
||||
*/
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
COOKIE_DOMAIN,
|
||||
REFRESH_TOKEN,
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
} from "@/src/shared/config";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || "";
|
||||
const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
|
||||
|
||||
async function handler(
|
||||
request: Request,
|
||||
params: { url: string[] },
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
request: Request,
|
||||
params: { url: string[] },
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
) {
|
||||
const cookieStore = await cookies();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
// silent refresh
|
||||
if (isRefreshHandler) {
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN);
|
||||
headers[CLAIM_NAME] = refreshToken?.value ?? "";
|
||||
|
||||
if (!headers[CLAIM_NAME]) {
|
||||
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
|
||||
const cookieStore = await cookies();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
const deviceId = request.headers.get("X-Device-Id");
|
||||
if (deviceId) {
|
||||
headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
try {
|
||||
if (!isRefreshHandler) {
|
||||
body = await request.json();
|
||||
// silent refresh
|
||||
if (isRefreshHandler) {
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN);
|
||||
headers[CLAIM_NAME] = refreshToken?.value ?? "";
|
||||
|
||||
if (!headers[CLAIM_NAME]) {
|
||||
return NextResponse.json(
|
||||
{ message: "Can't refresh" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
if (!isRefreshHandler) {
|
||||
body = await request.json();
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
console.log("Invalid Credentials ");
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid Credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
const result = await fetch(
|
||||
`${SERVER_API_URL}/${params.url.join("/")}`,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid Credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = result.headers.get(ACCESS_TOKEN);
|
||||
const refreshToken = result.headers.get(REFRESH_TOKEN);
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (accessToken && userId) {
|
||||
const response = NextResponse.json({
|
||||
[ACCESS_TOKEN]: accessToken,
|
||||
[AUTH_USER_ID]: userId,
|
||||
});
|
||||
|
||||
if (refreshToken) {
|
||||
cookieStore.set(REFRESH_TOKEN, refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
domain: getCookieDomain(),
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid Credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ message: "Server Error", error },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = result.headers.get(ACCESS_TOKEN);
|
||||
const refreshToken = result.headers.get(REFRESH_TOKEN);
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (accessToken && userId) {
|
||||
const response = NextResponse.json({
|
||||
[ACCESS_TOKEN]: accessToken,
|
||||
[AUTH_USER_ID]: userId,
|
||||
});
|
||||
|
||||
if (refreshToken) {
|
||||
cookieStore.set(REFRESH_TOKEN, refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid Credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ message: "Server Error", error },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
props: { params: Promise<{ url: string[] }> },
|
||||
request: Request,
|
||||
props: { params: Promise<{ url: string[] }> },
|
||||
) {
|
||||
const params = await props.params;
|
||||
return handler(request, params, "POST");
|
||||
const params = await props.params;
|
||||
return handler(request, params, "POST");
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
props: { params: Promise<{ url: string[] }> },
|
||||
request: Request,
|
||||
props: { params: Promise<{ url: string[] }> },
|
||||
) {
|
||||
const params = await props.params;
|
||||
return handler(request, params, "PUT");
|
||||
const params = await props.params;
|
||||
return handler(request, params, "PUT");
|
||||
}
|
||||
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
|
||||
|
||||
const { hostname } = window.location;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
|
||||
|
||||
return hostname;
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoginPage } from "@/src/features/auth/login";
|
||||
export default LoginPage;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const hostname = request.nextUrl.hostname;
|
||||
const redirectUrl = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "https://portal.hasuon.or.kr";
|
||||
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") {
|
||||
return NextResponse.redirect(new URL("/", request.url)); // 로컬 및 개발 환경
|
||||
} else {
|
||||
return NextResponse.redirect(new URL(redirectUrl)); // 공단 환경
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PROXY_HOST } from "@/src/shared/config/env";
|
||||
|
||||
export const DEV_URL_FO = "/operation-service/api/v1";
|
||||
export const DEV_URL_FO = "/operation-service";
|
||||
export const LOCAL_URL = PROXY_HOST;
|
||||
export const DEV_URL_WR = "/reuse-service/api/v1";
|
||||
export const DEV_URL_WR = "/reuse-service";
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { CLAIM_NAME, SITE_ID } from "@/src/shared/config/env";
|
||||
import { tokenManager } from "@/src/entities/auth/lib/token-manager";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { DEV_URL_FO, DEV_URL_WR, LOCAL_URL } from "./config/env";
|
||||
|
||||
const LOGIN_PAGE_URL =
|
||||
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
|
||||
|
||||
// client 화면인 경우, 토큰 만료여부 점검하여 로그인 페이지로 이동
|
||||
const handleUnauthorizedResponse = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const remainingMs = tokenManager.getRemainingMsFromToken(CLAIM_NAME);
|
||||
const accessToken = tokenManager.getToken(CLAIM_NAME);
|
||||
const remainingMs = accessToken ? getRemainingMs(accessToken) : null;
|
||||
const isExpired = remainingMs === null || remainingMs <= 0;
|
||||
|
||||
if (!isExpired) return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { LoginApi } from "@/src/entities/auth";
|
||||
import { authService } from "@/src/entities/auth";
|
||||
import { useEffect } from "react";
|
||||
import { AUTH_USER_ID } from "@/src/shared/config";
|
||||
import { getCookie } from "@/src/shared/lib/cookie";
|
||||
@@ -17,7 +17,7 @@ export default function Home() {
|
||||
if (!checkToken) {
|
||||
async function doLogin() {
|
||||
try {
|
||||
await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" });
|
||||
await authService.login({ userId: "CLEANWATER", password: "gktneh12!@" });
|
||||
window.location.href = "/pages/dashboard";
|
||||
} catch (err) {
|
||||
console.error("로그인 실패", err);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NoFooterLayout } from "@common/components";
|
||||
import type { MenuItem } from "../../store/common";
|
||||
import { useFoStore } from "../../store";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
// import { authService } from "@/src/entities/auth";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* JWT를 decoding하면 만료시간을 읽을 수 있다.
|
||||
* JWT는 3부분으로 구성 : Header.Payload.Signature
|
||||
* Payload 부분에 만료시간(exp)이 있다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { authService } from "@/src/entities/auth";
|
||||
import { CLAIM_NAME } from "@/src/shared/config";
|
||||
import { getCookie } from "@/src/shared/lib";
|
||||
|
||||
interface JwtPayload {
|
||||
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
|
||||
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
|
||||
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
|
||||
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
|
||||
}
|
||||
|
||||
interface JwtState {
|
||||
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
|
||||
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
|
||||
exp: number | null; // payload에서 추출한 exp 값
|
||||
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
|
||||
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
|
||||
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
|
||||
}
|
||||
|
||||
const initialJwtState: JwtState = {
|
||||
token: "",
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function parseJwt(token: string): JwtPayload {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("유효한 JWT 형식이 아닙니다.");
|
||||
}
|
||||
|
||||
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
|
||||
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(padded)
|
||||
.split("")
|
||||
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
|
||||
.join("")
|
||||
);
|
||||
|
||||
return JSON.parse(jsonPayload) as JwtPayload;
|
||||
}
|
||||
|
||||
function formatRemainingMs(remainingMs: number | null): string {
|
||||
if (remainingMs === null) return "-";
|
||||
if (remainingMs <= 0) return "만료됨";
|
||||
|
||||
const totalSeconds = Math.floor(remainingMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${hours}시간 ${minutes}분 ${seconds}초`;
|
||||
}
|
||||
|
||||
function readJwtState(): JwtState {
|
||||
const token = getCookie(CLAIM_NAME) ?? "";
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
token: "",
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = parseJwt(token);
|
||||
|
||||
if (typeof payload.exp !== "number") {
|
||||
return {
|
||||
token,
|
||||
payload,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error: "JWT payload에 exp 값이 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
const expMs = payload.exp * 1000;
|
||||
|
||||
return {
|
||||
token,
|
||||
payload,
|
||||
exp: payload.exp,
|
||||
expDateLocal: new Date(expMs).toLocaleString(),
|
||||
remainingMs: Math.max(expMs - Date.now(), 0),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
token,
|
||||
payload: null,
|
||||
exp: null,
|
||||
expDateLocal: null,
|
||||
remainingMs: null,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "JWT 파싱 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
|
||||
const isRefreshingRef = useRef(false);
|
||||
|
||||
const handleSilentRefresh = async () => {
|
||||
try {
|
||||
await authService.silentRefresh();
|
||||
} catch (error) {
|
||||
console.error("Manual silent refresh failed:", error);
|
||||
} finally {
|
||||
setJwtState(readJwtState());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const syncJwtState = async () => {
|
||||
const nextState = readJwtState();
|
||||
|
||||
if (
|
||||
nextState.remainingMs !== null &&
|
||||
nextState.remainingMs > 0 &&
|
||||
nextState.remainingMs < 5 * 60 * 1000 &&
|
||||
!isRefreshingRef.current
|
||||
) {
|
||||
isRefreshingRef.current = true;
|
||||
try {
|
||||
await authService.silentRefresh();
|
||||
} catch (error) {
|
||||
console.error("JWT test silent refresh failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setJwtState(readJwtState());
|
||||
|
||||
if (
|
||||
nextState.remainingMs === null ||
|
||||
nextState.remainingMs >= 5 * 60 * 1000 ||
|
||||
nextState.remainingMs <= 0
|
||||
) {
|
||||
isRefreshingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void syncJwtState();
|
||||
const timer = window.setInterval(() => {
|
||||
void syncJwtState();
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-8 text-base">
|
||||
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
void handleSilentRefresh();
|
||||
}}
|
||||
>
|
||||
Silent Refresh 호출
|
||||
</button>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">쿠키명</div>
|
||||
<div>{CLAIM_NAME}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">exp</div>
|
||||
<div>{jwtState.exp ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">만료시각</div>
|
||||
<div>{jwtState.expDateLocal ?? "-"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">남은시간</div>
|
||||
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">상태</div>
|
||||
<div>{jwtState.error ?? "정상"}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border p-4">
|
||||
<div className="font-semibold">payload</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
|
||||
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useFoStore } from "@/app/store";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMenuStore } from "@/src/shared/lib";
|
||||
import { PageLayout } from "@/src/shared/ui/page-layout";
|
||||
import { authService } from "@/src/entities/auth";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -28,6 +29,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
authService={authService}
|
||||
currentPath={pathname ?? ""}
|
||||
handleMenuClick={handleClick}
|
||||
>
|
||||
|
||||
@@ -5,9 +5,9 @@ const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || "";
|
||||
const LX_GEOSERVER_URL = process.env.NEXT_PUBLIC_LX_GEOSERVER_URL || "";
|
||||
const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
|
||||
const SEWERAGE_API_URL = process.env.SEWERAGE_API_URL || "";
|
||||
const serverApiUrl = SERVER_API_CS_URL;
|
||||
const DC_URL = process.env.NEXT_PUBLIC_DC_URL || "";
|
||||
const DC_URL = process.env.DC_URL || "";
|
||||
|
||||
/** 공통: URLSearchParams의 값을 decodeURIComponent 적용하여 querystring 생성 */
|
||||
function toDecodedQueryString(params: URLSearchParams): string {
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/business_components": "workspace:*",
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/config": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@common/business_components": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.0.10",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -32,7 +35,8 @@
|
||||
"react-hook-form": "^7.54.2",
|
||||
"zod": "^3.24.2",
|
||||
"axios": "^1.8.1",
|
||||
"proj4": "^2.9.0"
|
||||
"proj4": "^2.9.0",
|
||||
"uuid": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { authService } from "@/src/entities/auth";
|
||||
import { images } from "@common/assets";
|
||||
|
||||
import "./login.css";
|
||||
|
||||
function LoginPage() {
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage("");
|
||||
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const userId = formData.get("id") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
try {
|
||||
await authService.login({ userId, password });
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "로그인에 실패했습니다.";
|
||||
setErrorMessage(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login fx">
|
||||
<div className="left_box fx">
|
||||
<div className="text">
|
||||
<h1>
|
||||
유역하수도통합플랫폼
|
||||
<br />
|
||||
<span>하수도통계 관리시스템</span>
|
||||
</h1>
|
||||
<p>
|
||||
유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을
|
||||
환영합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="img">
|
||||
<Image
|
||||
src={images.logo}
|
||||
width={229}
|
||||
height={46}
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
id="loginForm"
|
||||
name="loginForm"
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="login_box fx">
|
||||
<ul className="fx">
|
||||
<li>
|
||||
<h2>ID</h2>
|
||||
<div className="input_st st1">
|
||||
<i className="diveicon di-account-box" />
|
||||
<input
|
||||
type="text"
|
||||
className="id"
|
||||
title="아이디를 입력하세요."
|
||||
id="id"
|
||||
name="id"
|
||||
maxLength={12}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<h2>PASSWORD</h2>
|
||||
<div className="input_st st2">
|
||||
<i className="diveicon di-key" />
|
||||
<input
|
||||
type="password"
|
||||
className="input_style"
|
||||
maxLength={25}
|
||||
title="비밀번호를 입력하세요."
|
||||
id="password"
|
||||
name="password"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bottom_box fx">
|
||||
<div className="checks fx">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="checkId"
|
||||
title="로그인ID 저장여부"
|
||||
id="checkId"
|
||||
/>
|
||||
<label htmlFor="checkId">아이디저장</label>
|
||||
</div>
|
||||
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="error_message">
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="login_btn fx">
|
||||
<span>LOGIN</span>
|
||||
</button>
|
||||
<div className="footer">
|
||||
<div className="copy fx">
|
||||
<p>copyright © 2023 KECO. All rights reserved.</p>
|
||||
</div>
|
||||
<div className="fot_btn fx">
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
개인정보처리방침
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
저작권정책안내
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
이메일무단수집거부
|
||||
</a>
|
||||
</div>
|
||||
<p className="addr fx">
|
||||
<span>
|
||||
주소 : (22689) 인천광역시 서구 환경로 42(경서동
|
||||
종합환경연구단지)
|
||||
</span>
|
||||
<span>
|
||||
전화 : (시스템) 032-590-4321, 3752, 4314 / (통
|
||||
계) 032-590-3723, 3722
|
||||
</span>
|
||||
<span>팩스 :032-590-3729</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoginPage };
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* OLYM Cascading Style Sheets
|
||||
* File Name : login.css
|
||||
* Description : 로그인 스타일시트
|
||||
@@ -24,7 +24,8 @@ body {
|
||||
}
|
||||
.login .left_box {
|
||||
width: 100%;
|
||||
background: url(/images/bg_login.jpg) no-repeat center/cover;
|
||||
background: url(../../../../public/images/bg_login.jpg) no-repeat
|
||||
center/cover;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 7% 5% 5% 5%;
|
||||
@@ -117,6 +118,20 @@ body {
|
||||
color: #327af5;
|
||||
font-size: 20px;
|
||||
}
|
||||
.login .login_box .error_message {
|
||||
margin: 20px 0;
|
||||
padding: 12px 20px;
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 5px;
|
||||
color: #c33;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.login .login_box .error_message span {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
.login .login_box .login_btn {
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
} from "@/src/shared/config";
|
||||
import { getCookie } from "@/src/shared/lib";
|
||||
import {
|
||||
type AuthPayload,
|
||||
type AuthService,
|
||||
getRemainingMs,
|
||||
} from "@common/auth";
|
||||
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
|
||||
import {
|
||||
clearRefreshTimer,
|
||||
setToken,
|
||||
setupRefreshTimer,
|
||||
tokenManager,
|
||||
} from "../lib/token-manager";
|
||||
|
||||
import { clearTabStorePersist } from "@/src/store/tab-store";
|
||||
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
const prevTokenId = getCookie(AUTH_USER_ID);
|
||||
if (prevTokenId !== undefined && prevTokenId !== tokenId) {
|
||||
clearTabStorePersist();
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function silentRefresh(): Promise<void> {
|
||||
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"X-Device-Id": getOrCreateDeviceId(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
|
||||
}
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[silentRefresh] Failed at ${new Date().toISOString()}`,
|
||||
error,
|
||||
);
|
||||
tokenManager.clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
tokenManager.clearAuthToken();
|
||||
clearDeviceId();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
|
||||
export const authService: AuthService = {
|
||||
login,
|
||||
silentRefresh,
|
||||
declineSessionExtend: clearRefreshTimer,
|
||||
logout,
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
|
||||
import type { LoginService, ILogin } from "../model/login-interface";
|
||||
import { tokenManager } from "../lib/token-manager";
|
||||
import {PROXY_HOST, ACCESS_TOKEN, AUTH_USER_ID,SITE_ID, CLAIM_NAME, ENDPOINT, JWT_EXPIRATION_TIME } from "../../../shared/config";
|
||||
|
||||
export class LoginApiService implements LoginService {
|
||||
private static instance: LoginApiService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LoginApiService {
|
||||
if (!LoginApiService.instance) {
|
||||
LoginApiService.instance = new LoginApiService();
|
||||
}
|
||||
return LoginApiService.instance;
|
||||
}
|
||||
|
||||
public async login(payload: ILogin): Promise<void> {
|
||||
try {
|
||||
const response = await fetch( PROXY_HOST+ ENDPOINT.USER_SERVICE.LOGIN, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
password: payload.password,
|
||||
}),
|
||||
});
|
||||
// const data = response.headers.get(ACCESS_TOKEN);
|
||||
const accessToken = response.headers.get(ACCESS_TOKEN);
|
||||
const tokenId = response.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
tokenManager.setToken("site-id-key", SITE_ID);
|
||||
tokenManager.setupRefreshTimer(
|
||||
() => this.silentRefresh(),
|
||||
JWT_EXPIRATION_TIME,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
console.log(' login err ' , error)
|
||||
throw new Error(`Login failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async silentRefresh(): Promise<void> {
|
||||
console.log(' silentRefresh ')
|
||||
|
||||
try {
|
||||
const response = await fetch(PROXY_HOST+
|
||||
`${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
|
||||
{ method: "PUT" },
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
} catch (error) {
|
||||
tokenManager.clearAuthToken();
|
||||
throw new Error(`Silent refresh failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoginApi = LoginApiService.getInstance();
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { authService } from "./api/auth-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
export { tokenManager } from "./lib/token-manager";
|
||||
|
||||
@@ -1,68 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { client } from "@/src/shared/api";
|
||||
import type { IUserInfo } from "@/src/shared/store";
|
||||
import { isAtLoginRedirectUrl } from "@common/network";
|
||||
import { useUserStore, getCookie } from "@/src/shared/lib";
|
||||
import { ENDPOINT, AUTH_USER_ID } from "@/src/shared/config";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const LOGIN_PAGE_URL =
|
||||
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { client } from "@/src/shared/api";
|
||||
import {
|
||||
AUTH_LOGIN_REDIRECT_URL,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
} from "@/src/shared/config";
|
||||
import { useUserStore } from "@/src/shared/lib";
|
||||
import type { IUserInfo } from "@/src/shared/store";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { isAtLoginRedirectUrl } from "@common/network";
|
||||
|
||||
import { authService } from "../api/auth-service";
|
||||
|
||||
import { getToken, setupRefreshTimer } from "./token-manager";
|
||||
|
||||
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const { userInfo, setUserInfo } = useUserStore((state) => state);
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const setUserInfo = useUserStore((state) => state.setUserInfo);
|
||||
|
||||
async function authCheck() {
|
||||
if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = getCookie(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
const response = await client.get<IUserInfo>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
|
||||
);
|
||||
if (!userInfo) setUserInfo(response.data);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await authCheck();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) {
|
||||
return;
|
||||
useEffect(() => {
|
||||
// 페이지 새로고침 시 silentRefresh 타이머 복구
|
||||
const token = getToken(CLAIM_NAME);
|
||||
if (token) {
|
||||
const remainingMs = getRemainingMs(token);
|
||||
if (remainingMs > 0) {
|
||||
console.log(
|
||||
`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
|
||||
);
|
||||
setupRefreshTimer(authService.silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
// const hostname = window.location.hostname;
|
||||
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
|
||||
|
||||
// // 개발 환경에서는 localhost 도메인 사용
|
||||
// if (
|
||||
// hostname === "localhost" ||
|
||||
// hostname === "127.0.0.1" ||
|
||||
// hostname === "con.mind-one.co.kr"
|
||||
// ) {
|
||||
// router.push("/"); // 개발시 홈으로 돌아가기
|
||||
// }
|
||||
async function authCheck() {
|
||||
const userId = getToken(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
// // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
|
||||
// // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용
|
||||
// if (hostname.includes("hasuon.or.kr"))
|
||||
// window.location.assign(LOGIN_PAGE_URL); // 세션 만료 시 CM 로그인 페이지로 리다이렉트
|
||||
if (userInfo) return;
|
||||
|
||||
// 개발/운영 환경에서 로그인 URL 분기는 환경 변수로 처리 (.env.*)
|
||||
window.location.assign(LOGIN_PAGE_URL);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
const response = await client.get<IUserInfo>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
|
||||
);
|
||||
setUserInfo(response.data);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
authCheck().catch((error) => {
|
||||
// 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨
|
||||
if (isAxiosError(error) && error.response?.status === 401) return;
|
||||
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동
|
||||
useEffect(() => {
|
||||
function handleAuthLogout() {
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("auth:logout", handleAuthLogout);
|
||||
return () =>
|
||||
window.removeEventListener("auth:logout", handleAuthLogout);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export { AuthWrapper };
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const DEVICE_ID_KEY = "x-device-id";
|
||||
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
|
||||
const existing = localStorage.getItem(DEVICE_ID_KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
const id = uuidv4();
|
||||
localStorage.setItem(DEVICE_ID_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function clearDeviceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
localStorage.removeItem(DEVICE_ID_KEY);
|
||||
}
|
||||
@@ -1,109 +1,15 @@
|
||||
import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config";
|
||||
import { getCookie, removeCookie, setCookie } from "@/src/shared/lib/cookie";
|
||||
import { authConfig } from "@/src/shared/config/auth";
|
||||
import { createTokenManager } from "@common/auth";
|
||||
|
||||
type JwtPayload = {
|
||||
sub?: string;
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
export class TokenManager {
|
||||
private static instance: TokenManager;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
public getToken(key: string): string | undefined {
|
||||
return getCookie(key);
|
||||
}
|
||||
|
||||
private parseJwt(token: string): JwtPayload {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("유효한 JWT 형식이 아닙니다.");
|
||||
}
|
||||
|
||||
const base64Url = parts[1] ?? "";
|
||||
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(padded)
|
||||
.split("")
|
||||
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
|
||||
.join("")
|
||||
);
|
||||
|
||||
return JSON.parse(jsonPayload) as JwtPayload;
|
||||
}
|
||||
|
||||
public getRemainingMsFromToken(key: string = CLAIM_NAME): number | null {
|
||||
const token = this.getToken(key);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.parseJwt(token);
|
||||
if (typeof payload.exp !== "number") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload.exp * 1000 - Date.now();
|
||||
} catch (error) {
|
||||
console.error("JWT 파싱 오류:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public isExpiredToken(key: string = CLAIM_NAME): boolean {
|
||||
const remainingMs = this.getRemainingMsFromToken(key);
|
||||
return remainingMs === null || remainingMs <= 0;
|
||||
}
|
||||
|
||||
public setToken(key: string, value: string): void {
|
||||
setCookie(key, value, {
|
||||
secure: location.protocol === "https:",
|
||||
sameSite: "lax",
|
||||
});
|
||||
}
|
||||
|
||||
public removeToken(key: string): void {
|
||||
removeCookie(key);
|
||||
}
|
||||
|
||||
public setupRefreshTimer(
|
||||
callback: () => Promise<void>,
|
||||
expirationTime: number,
|
||||
): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
callback().catch((error) => {
|
||||
console.error("Silent refresh failed:", error);
|
||||
this.clearAuthToken();
|
||||
});
|
||||
}, expirationTime - 60000);
|
||||
}
|
||||
|
||||
public clearAuthToken(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.removeToken(CLAIM_NAME);
|
||||
this.removeToken(AUTH_USER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearRefreshTimer,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
export { MenuAuthorityApi } from "./api/menu-authority-service";
|
||||
export {
|
||||
menuAuthorityFormSchema,
|
||||
type MenuAuthority,
|
||||
type MenuAuthorityPayload,
|
||||
type MenuAuthorityFormData,
|
||||
} from "./model/menu-authority-interface";
|
||||
export { AuthorityButton } from "./ui/authority-button";
|
||||
export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { MenuApi } from "./api/menu-service";
|
||||
export type { MenuItem, MenuService } from "./model/menu-interface";
|
||||
export { MenuWrapper } from "./lib/menu-wrapper";
|
||||
export type { MenuItem } from "./model/menu-interface";
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect } from "react";
|
||||
import { images } from "@common/assets";
|
||||
import { LoginApi } from "@/src/entities/auth";
|
||||
import "./login.css";
|
||||
|
||||
function LoginPage() {
|
||||
useEffect(() => {
|
||||
const hostname = window.location.hostname;
|
||||
if (hostname.includes("hasuon.or.kr")) {
|
||||
window.location.href =
|
||||
process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const userId = formData.get("id") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
await LoginApi.login({ userId, password });
|
||||
window.location.href = "/pages/dashboard";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login fx">
|
||||
<div className="left_box fx">
|
||||
<div className="text">
|
||||
<h1>
|
||||
하수도정보시스템
|
||||
<br />
|
||||
<span>CMS 통합관리시스템</span>
|
||||
</h1>
|
||||
<p>하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.</p>
|
||||
</div>
|
||||
<div className="img">
|
||||
<Image src={images.logo} width={229} height={46} alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
id="loginForm"
|
||||
name="loginForm"
|
||||
method="post"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="login_box fx">
|
||||
<ul className="fx">
|
||||
<li>
|
||||
<h2>ID</h2>
|
||||
<div className="input_st st1">
|
||||
<i className="diveicon di-account-box" />
|
||||
<input
|
||||
type="text"
|
||||
className="id"
|
||||
title="아이디를 입력하세요."
|
||||
id="id"
|
||||
name="id"
|
||||
maxLength={12}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<h2>PASSWORD</h2>
|
||||
<div className="input_st st2">
|
||||
<i className="diveicon di-key" />
|
||||
<input
|
||||
type="password"
|
||||
className="input_style"
|
||||
maxLength={25}
|
||||
title="비밀번호를 입력하세요."
|
||||
id="password"
|
||||
name="password"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bottom_box fx">
|
||||
<div className="checks fx">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="checkId"
|
||||
title="로그인ID 저장여부"
|
||||
id="checkId"
|
||||
/>
|
||||
<label htmlFor="checkId">아이디저장</label>
|
||||
</div>
|
||||
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
|
||||
</div>
|
||||
<button type="submit" className="login_btn fx">
|
||||
<span>LOGIN</span>
|
||||
</button>
|
||||
<div className="footer">
|
||||
<div className="copy fx">
|
||||
<p>copyright © 2023 KECO. All rights reserved.</p>
|
||||
</div>
|
||||
<div className="fot_btn fx">
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
개인정보처리방침
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
저작권정책안내
|
||||
</a>
|
||||
<a
|
||||
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
|
||||
target="_blank"
|
||||
>
|
||||
이메일무단수집거부
|
||||
</a>
|
||||
</div>
|
||||
<p className="addr fx">
|
||||
<span>
|
||||
주소 : (22689) 인천광역시 서구 환경로 42(경서동
|
||||
종합환경연구단지)
|
||||
</span>
|
||||
<span>
|
||||
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
|
||||
3722
|
||||
</span>
|
||||
<span>팩스 :032-590-3729</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoginPage };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PROXY_HOST } from "./env";
|
||||
|
||||
export const BASE_URL = `${PROXY_HOST}/server`;
|
||||
export const BASE_URL = `${PROXY_HOST}`;
|
||||
|
||||
export const ENDPOINT = {
|
||||
ATTACH_SERVICE: {
|
||||
@@ -14,13 +14,21 @@ export const ENDPOINT = {
|
||||
},
|
||||
USER_SERVICE: {
|
||||
LOGIN: "/user-service/login",
|
||||
LOGIN_2FA: "/user-service/api/v1/auth/login",
|
||||
USERS: "/user-service/api/v1/users",
|
||||
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
|
||||
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
|
||||
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
|
||||
ROLES: "/user-service/api/v1/roles",
|
||||
LOGINLOG: "/user-service/api/v1/login-logs",
|
||||
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
|
||||
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",
|
||||
SITES: "/cms-service/api/v1/sites",
|
||||
IPS: "/cms-service/api/v1/site-ips",
|
||||
DOMAINS: "/cms-service/api/v1/site-dmns",
|
||||
@@ -29,6 +37,8 @@ export const ENDPOINT = {
|
||||
MENUS: "/cms-service/api/v1/menus",
|
||||
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
||||
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
|
||||
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
|
||||
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
|
||||
CONTENTS: "/cms-service/api/v1/contents",
|
||||
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
|
||||
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
|
||||
@@ -44,13 +54,32 @@ export const ENDPOINT = {
|
||||
SURVEY: "/cms-service/api/v1/srvy",
|
||||
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
|
||||
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
|
||||
ZIPS: "/cms-service/api/v1/zips",
|
||||
GROUPS: "/cms-service/api/v1/groups",
|
||||
GROUP_USERS: "/cms-service/api/v1/group-users",
|
||||
MANAGER_IP: "/cms-service/api/v1/mgrip",
|
||||
APILOG: "/cms-service/api/v1/api-logs",
|
||||
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
|
||||
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
|
||||
},
|
||||
BOARD_SERVICE: {
|
||||
BBS: "/board-service/api/v1/bbs",
|
||||
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
|
||||
POSTS: "/board-service/api/v1/posts",
|
||||
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
|
||||
POST_EVALUATION: "/board-service/api/v1/pstEvl",
|
||||
},
|
||||
SEWERAGE_SERVICE: {
|
||||
PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts",
|
||||
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
|
||||
},
|
||||
IC_SERVICE: {
|
||||
GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics",
|
||||
},
|
||||
DC_SERVICE: {
|
||||
DATA_STATUS: "/dc-service/api/v1/dc/dataStatus",
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export const JWT_EXPIRATION_TIME = 1800000;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AuthConfig } from "@common/auth";
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
COOKIE_DOMAIN,
|
||||
REFRESH_TOKEN,
|
||||
} from "./env";
|
||||
|
||||
export const authConfig: AuthConfig = {
|
||||
authUserId: AUTH_USER_ID || "token-id",
|
||||
claimName: CLAIM_NAME || "Authorization",
|
||||
cookieDomain: COOKIE_DOMAIN,
|
||||
accessToken: ACCESS_TOKEN || "access-token",
|
||||
refreshToken: REFRESH_TOKEN || "refresh-token",
|
||||
};
|
||||
@@ -1,14 +1,30 @@
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "FO";
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_WR_URL = process.env.NEXT_PUBLIC_WR_SERVER_API_URL ||'http://con.mind-one.co.kr:11080/api/v1';
|
||||
export const SERVER_API_FO_URL = process.env.NEXT_PUBLIC_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
|
||||
export const SERVER_API_CS_URL = process.env.NEXT_PUBLIC_CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
|
||||
export const SERVER_API_WR_URL =
|
||||
process.env.WR_SERVER_API_URL ||
|
||||
"http://con.mind-one.co.kr:11083/reuse-service/api/v1";
|
||||
export const SERVER_API_FO_URL =
|
||||
process.env.FO_SERVER_API_URL ||
|
||||
"http://con.mind-one.co.kr:11083/operation-service/api/v1";
|
||||
export const SERVER_API_CS_URL =
|
||||
process.env.CS_SERVER_API_URL ||
|
||||
"http://dev.onasys.co.kr:8000";
|
||||
export const SERVER_API_URL =
|
||||
process.env.CS_SERVER_API_URL ||
|
||||
"http://dev.onasys.co.kr:8000";
|
||||
export const SERVER_API_CS_TUS_URL =
|
||||
process.env.NEXT_PUBLIC_CS_TUS_UPLOAD ||
|
||||
"/attach-service/api/v1/tus/upload";
|
||||
export const AUTH_LOGIN_REDIRECT_URL =
|
||||
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
export const SERVER_SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL;
|
||||
export const SERVER_SEWERAGE_API_URL = process.env.SEWERAGE_API_URL;
|
||||
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./api";
|
||||
export * from "./env";
|
||||
|
||||
export const DEFAULT_LIST_SIZE = 10;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MenuItem } from "@/src/entities/menu";
|
||||
|
||||
export const BBS_NOTICE_MENU_MARKER = "bbsnotice";
|
||||
|
||||
export function isBbsNoticeMenu(item: MenuItem): boolean {
|
||||
const marker = BBS_NOTICE_MENU_MARKER;
|
||||
return (
|
||||
item.urlPath?.toLowerCase().includes(marker) ||
|
||||
item.menuEngNm?.toLowerCase().includes(marker) ||
|
||||
item.menuPath?.toLowerCase().includes(marker)
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,10 @@ export { useMenuStore } from "./use-menu-store";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
export { usePrompt } from "./use-prompt";
|
||||
export { getCookie, setCookie, removeCookie } from "./cookie";
|
||||
export {
|
||||
buildStatisticsComponentImportPath,
|
||||
menuMatchesStatisticsTabId,
|
||||
parseStatisticsMenuUrlPath,
|
||||
resolveStatisticsCategory,
|
||||
resolveStatisticsTabId,
|
||||
} from "./statistics-menu-url";
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { MenuItem } from "@/src/entities/menu";
|
||||
|
||||
export type ParsedStatisticsMenuUrl = {
|
||||
categoryFromPath: string | null;
|
||||
componentName: string | null;
|
||||
rawUrlPath: string;
|
||||
};
|
||||
|
||||
/** 메뉴 `urlPath`에서 통계 화면 카테고리·컴포넌트명을 파싱합니다. */
|
||||
export function parseStatisticsMenuUrlPath(
|
||||
urlPath: string,
|
||||
): ParsedStatisticsMenuUrl {
|
||||
const trimmed = (urlPath ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return { categoryFromPath: null, componentName: null, rawUrlPath: trimmed };
|
||||
}
|
||||
|
||||
let pathPart = trimmed;
|
||||
let queryString = "";
|
||||
const queryIndex = trimmed.indexOf("?");
|
||||
if (queryIndex >= 0) {
|
||||
pathPart = trimmed.slice(0, queryIndex);
|
||||
queryString = trimmed.slice(queryIndex + 1);
|
||||
}
|
||||
|
||||
const tabParam = new URLSearchParams(queryString).get("tab")?.trim() || null;
|
||||
const segments = pathPart.replace(/^\/+/, "").split("/").filter(Boolean);
|
||||
|
||||
let categoryFromPath: string | null = null;
|
||||
if (segments[0] === "statistics" && segments.length >= 2) {
|
||||
categoryFromPath = segments[1] ?? null;
|
||||
}
|
||||
|
||||
let componentName = tabParam;
|
||||
if (!componentName) {
|
||||
if (!pathPart.includes("/") && !queryString) {
|
||||
componentName = trimmed;
|
||||
} else if (segments.length === 1 && segments[0] !== "statistics") {
|
||||
componentName = segments[0] ?? null;
|
||||
} else if (segments[0] === "statistics" && segments.length >= 3) {
|
||||
componentName = segments[segments.length - 1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return { categoryFromPath, componentName, rawUrlPath: trimmed };
|
||||
}
|
||||
|
||||
/** 동적 import용 카테고리 폴더명 (`input`, `review` 등) */
|
||||
export function resolveStatisticsCategory(
|
||||
urlPath: string,
|
||||
parentEngNm: string,
|
||||
): string {
|
||||
const { categoryFromPath } = parseStatisticsMenuUrlPath(urlPath);
|
||||
return categoryFromPath || parentEngNm || "";
|
||||
}
|
||||
|
||||
/** 탭 ID·URL `?tab=` 값 - 컴포넌트 파일명(확장자 제외) */
|
||||
export function resolveStatisticsTabId(urlPath: string): string {
|
||||
const { componentName, rawUrlPath } = parseStatisticsMenuUrlPath(urlPath);
|
||||
return componentName || rawUrlPath;
|
||||
}
|
||||
|
||||
/** `@/src/components/statistics/{category}/{Component}` 또는 루트 컴포넌트 경로 */
|
||||
export function buildStatisticsComponentImportPath(
|
||||
urlPath: string,
|
||||
parentEngNm: string,
|
||||
): string {
|
||||
const { componentName } = parseStatisticsMenuUrlPath(urlPath);
|
||||
const category = resolveStatisticsCategory(urlPath, parentEngNm);
|
||||
const name = componentName || urlPath;
|
||||
if (!category) {
|
||||
return `@/src/components/statistics/${name}`;
|
||||
}
|
||||
return `@/src/components/statistics/${category}/${name}`;
|
||||
}
|
||||
|
||||
export function menuMatchesStatisticsTabId(
|
||||
menu: MenuItem,
|
||||
tabId: string,
|
||||
): boolean {
|
||||
if (!tabId) return false;
|
||||
if (menu.urlPath === tabId) return true;
|
||||
return resolveStatisticsTabId(menu.urlPath) === tabId;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import type { MenuItem } from "@/src/entities/menu";
|
||||
import { MenuAuthorityApi } from "@/src/entities/menu-authority";
|
||||
import type { PageMenuAuthority } from "@/src/entities/menu-authority/model/menu-authority-interface";
|
||||
import { isBbsNoticeMenu } from "@/src/shared/lib/bbs-notice-menu";
|
||||
import { resolveStatisticsTabId } from "@/src/shared/lib/statistics-menu-url";
|
||||
|
||||
export interface Tab {
|
||||
id: string; // 고유 ID (urlPath)
|
||||
label: string; // 탭에 표시될 이름
|
||||
path: string; // 메뉴 경로
|
||||
urlPath: string; // url 경로
|
||||
parentEngNm: string; // 부모 menuEngNm
|
||||
menuItem: MenuItem; // 메뉴 정보
|
||||
/** undefined: 미조회, null: 조회했으나 없음/실패 */
|
||||
pageMenuAuthority?: PageMenuAuthority | null;
|
||||
}
|
||||
|
||||
async function fetchTabPageMenuAuthority(
|
||||
menuCd: number,
|
||||
apply: (authority: PageMenuAuthority | null) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd);
|
||||
apply(response?.[0] ?? null);
|
||||
} catch {
|
||||
apply(null);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTabPageMenuAuthorityFetch(
|
||||
get: () => TabStore,
|
||||
set: (
|
||||
partial:
|
||||
| Partial<TabStore>
|
||||
| ((state: TabStore) => Partial<TabStore>),
|
||||
) => void,
|
||||
tabId: string,
|
||||
menuCd: number,
|
||||
): void {
|
||||
void fetchTabPageMenuAuthority(menuCd, (authority) => {
|
||||
const { tabs } = get();
|
||||
if (!tabs.some((tab) => tab.id === tabId)) return;
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, pageMenuAuthority: authority } : tab,
|
||||
),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
type TabStoreSetState = (
|
||||
partial:
|
||||
| Partial<TabStore>
|
||||
| ((state: TabStore) => Partial<TabStore>),
|
||||
) => void;
|
||||
|
||||
const tabStoreSetState: TabStoreSetState = (partial) => {
|
||||
useTabStoreHook.setState(partial);
|
||||
};
|
||||
|
||||
function ensureTabPageMenuAuthority(
|
||||
get: () => TabStore,
|
||||
set: TabStoreSetState,
|
||||
tab: Tab,
|
||||
): void {
|
||||
if (tab.pageMenuAuthority !== undefined) return;
|
||||
scheduleTabPageMenuAuthorityFetch(get, set, tab.id, tab.menuItem.menuCd);
|
||||
}
|
||||
|
||||
/** persist 복원·새로고침 후 활성 탭 메뉴 권한이 없으면 조회 */
|
||||
export function hydrateActiveTabPageMenuAuthority(): void {
|
||||
const { tabs, activeTabId } = getTabStoreState();
|
||||
if (!activeTabId) return;
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!tab) return;
|
||||
ensureTabPageMenuAuthority(getTabStoreState, tabStoreSetState, tab);
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
menuList: MenuItem[] | null;
|
||||
addTab: (menuItem: MenuItem) => boolean;
|
||||
removeTab: (tabId: string) => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
setMenuList: (menuList: MenuItem[] | null) => void;
|
||||
closeAllTabs: () => void;
|
||||
closeOtherTabs: (tabId: string) => void;
|
||||
closeTabsToLeft: (tabId: string) => void;
|
||||
closeTabsToRight: (tabId: string) => void;
|
||||
}
|
||||
|
||||
export const TAB_STORE_PERSIST_KEY = "st-tab-store";
|
||||
const TAB_STORE_VERSION = 1;
|
||||
const MAX_TABS = 10;
|
||||
|
||||
function tabForPersist(tab: Tab): Tab {
|
||||
return {
|
||||
...tab,
|
||||
menuItem: { ...tab.menuItem, children: null },
|
||||
pageMenuAuthority: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersistedTab(tab: Tab): Tab {
|
||||
const tabId = resolveStatisticsTabId(tab.urlPath || tab.id);
|
||||
return tabId === tab.id ? tab : { ...tab, id: tabId };
|
||||
}
|
||||
|
||||
function sanitizePersistedTabsAndActive(
|
||||
tabs: Tab[],
|
||||
activeTabId: string | null,
|
||||
): { tabs: Tab[]; activeTabId: string | null } {
|
||||
let nextTabs = Array.isArray(tabs)
|
||||
? tabs.filter((tab) => !isBbsNoticeMenu(tab.menuItem)).map(normalizePersistedTab)
|
||||
: [];
|
||||
if (nextTabs.length > MAX_TABS) nextTabs = nextTabs.slice(0, MAX_TABS);
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
return { tabs: [], activeTabId: null };
|
||||
}
|
||||
|
||||
let nextActive = activeTabId;
|
||||
if (nextActive && !nextTabs.some((t) => t.id === nextActive)) {
|
||||
nextActive = nextTabs[0]?.id ?? null;
|
||||
}
|
||||
if (nextActive == null && nextTabs.length > 0) {
|
||||
nextActive = nextTabs[0]?.id ?? null;
|
||||
}
|
||||
|
||||
return { tabs: nextTabs, activeTabId: nextActive };
|
||||
}
|
||||
|
||||
const useTabStoreHook = create<TabStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
menuList: null,
|
||||
addTab: (menuItem: MenuItem) => {
|
||||
if (isBbsNoticeMenu(menuItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tabs, menuList } = get();
|
||||
const tabId = resolveStatisticsTabId(menuItem.urlPath || "");
|
||||
|
||||
const existingTab = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (existingTab) {
|
||||
set({ activeTabId: tabId });
|
||||
ensureTabPageMenuAuthority(get, set, existingTab);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tabs.length < MAX_TABS) {
|
||||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
label: menuItem.menuKornNm,
|
||||
path: menuItem.menuPath,
|
||||
urlPath: menuItem.urlPath,
|
||||
parentEngNm:
|
||||
menuList?.find(
|
||||
(m) => m.menuCd === menuItem.parentId,
|
||||
)?.menuEngNm || "",
|
||||
menuItem,
|
||||
pageMenuAuthority: undefined,
|
||||
};
|
||||
|
||||
set({
|
||||
tabs: [...tabs, newTab],
|
||||
activeTabId: tabId,
|
||||
});
|
||||
|
||||
scheduleTabPageMenuAuthorityFetch(
|
||||
get,
|
||||
set,
|
||||
tabId,
|
||||
menuItem.menuCd,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
removeTab: (tabId: string): void => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const newTabs = tabs.filter((tab) => tab.id !== tabId);
|
||||
|
||||
let newActiveTabId = activeTabId;
|
||||
if (activeTabId === tabId) {
|
||||
const closedTabIndex = tabs.findIndex(
|
||||
(tab) => tab.id === tabId,
|
||||
);
|
||||
if (newTabs.length > 0) {
|
||||
if (closedTabIndex > 0) {
|
||||
const prevTab = tabs[closedTabIndex - 1];
|
||||
newActiveTabId = prevTab ? prevTab.id : null;
|
||||
} else {
|
||||
const firstTab = newTabs[0];
|
||||
newActiveTabId = firstTab ? firstTab.id : null;
|
||||
}
|
||||
} else {
|
||||
newActiveTabId = null;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
});
|
||||
if (newActiveTabId) {
|
||||
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
|
||||
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
|
||||
}
|
||||
},
|
||||
setActiveTab: (tabId: string) => {
|
||||
set({ activeTabId: tabId });
|
||||
const tab = get().tabs.find((t) => t.id === tabId);
|
||||
if (tab) ensureTabPageMenuAuthority(get, set, tab);
|
||||
},
|
||||
setMenuList: (menuList: MenuItem[] | null) => {
|
||||
set({ menuList });
|
||||
},
|
||||
closeAllTabs: () => {
|
||||
set({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
},
|
||||
closeOtherTabs: (tabId: string) => {
|
||||
const { tabs } = get();
|
||||
const tabToKeep = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (tabToKeep) {
|
||||
set({
|
||||
tabs: [tabToKeep],
|
||||
activeTabId: tabId,
|
||||
});
|
||||
}
|
||||
},
|
||||
closeTabsToLeft: (tabId: string) => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (index <= 0) return;
|
||||
|
||||
const newTabs = tabs.slice(index);
|
||||
let newActiveTabId = activeTabId;
|
||||
if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) {
|
||||
newActiveTabId = tabId;
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
});
|
||||
if (newActiveTabId) {
|
||||
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
|
||||
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
|
||||
}
|
||||
},
|
||||
closeTabsToRight: (tabId: string) => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (index === -1 || index >= tabs.length - 1) return;
|
||||
|
||||
const newTabs = tabs.slice(0, index + 1);
|
||||
let newActiveTabId = activeTabId;
|
||||
if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) {
|
||||
newActiveTabId = tabId;
|
||||
}
|
||||
|
||||
set({
|
||||
tabs: newTabs,
|
||||
activeTabId: newActiveTabId,
|
||||
});
|
||||
if (newActiveTabId) {
|
||||
const nextActive = get().tabs.find((t) => t.id === newActiveTabId);
|
||||
if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: TAB_STORE_PERSIST_KEY,
|
||||
version: TAB_STORE_VERSION,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map(tabForPersist),
|
||||
activeTabId: state.activeTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const p = persistedState as Partial<
|
||||
Pick<TabStore, "tabs" | "activeTabId">
|
||||
> | null;
|
||||
const rawTabs = p?.tabs;
|
||||
const rawActive = p?.activeTabId;
|
||||
const { tabs, activeTabId } = sanitizePersistedTabsAndActive(
|
||||
(rawTabs as Tab[]) ?? [],
|
||||
typeof rawActive === "string" || rawActive === null
|
||||
? rawActive
|
||||
: null,
|
||||
);
|
||||
return {
|
||||
...currentState,
|
||||
tabs,
|
||||
activeTabId,
|
||||
};
|
||||
},
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) return;
|
||||
queueMicrotask(() => hydrateActiveTabPageMenuAuthority());
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useTabStore = useTabStoreHook;
|
||||
export const getTabStoreState = () => useTabStoreHook.getState();
|
||||
|
||||
// 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화
|
||||
export function clearTabStorePersist(): void {
|
||||
useTabStoreHook.getState().closeAllTabs();
|
||||
useTabStoreHook.persist.clearStorage();
|
||||
}
|
||||
+4
-4
@@ -11,9 +11,9 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SEWERAGE_API_URL = http://localhost:8883
|
||||
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
|
||||
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
|
||||
|
||||
@@ -6,11 +6,11 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
|
||||
@@ -8,10 +8,11 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
#NEXT_PUBLIC_PROXY_HOST = https://devlion.hasuon.or.kr
|
||||
#NEXT_PUBLIC_SUB_DOMAIN = http://front-sn-feature.iipbs-web.svc.cluster.local:13000
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
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_NGII_URL = http://172.29.143.87/poiact
|
||||
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=.hasuon.or.kr
|
||||
@@ -0,0 +1,17 @@
|
||||
import "../../../src/app/styles/base.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "유역하수도 통합 정보플랫폼",
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LoginPage } from "@/auth/login";
|
||||
export default LoginPage;
|
||||
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgedPipeFilters } from "./agedPipeUtils";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
type Props = {
|
||||
filters: AgedPipeFilters;
|
||||
onFiltersChange: (filters: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => MaybePromise;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function AgedPipeLayerSettingPanel({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onApply,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
|
||||
@@ -21,17 +25,74 @@ export default function AgedPipeLayerSettingPanel({
|
||||
setElapsedYearsText(String(filters.elapsedYears ?? ""));
|
||||
}, [filters.elapsedYears]);
|
||||
|
||||
const commitElapsedYears = () => {
|
||||
const commitElapsedYears = async () => {
|
||||
const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
|
||||
if (!validateElapsedYears(elapsedYears)) {
|
||||
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFilters = {
|
||||
...filters,
|
||||
elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText),
|
||||
elapsedYears,
|
||||
};
|
||||
onFiltersChange(nextFilters);
|
||||
onApply(nextFilters);
|
||||
await onApply(nextFilters);
|
||||
alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`);
|
||||
};
|
||||
|
||||
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
|
||||
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
|
||||
|
||||
const settingRow = (
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-slate-700">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className={numberInputClass}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={elapsedYearsText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={applyButtonClass}
|
||||
onClick={() => void commitElapsedYears()}
|
||||
title="적용"
|
||||
aria-label="경과년도 적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
{settingRow}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
|
||||
<button
|
||||
type="button"
|
||||
@@ -39,41 +100,14 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "›" : "⌄"}
|
||||
{isCollapsed ? "+" : "-"}
|
||||
</button>
|
||||
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={elapsedYearsText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
|
||||
onClick={commitElapsedYears}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
{settingRow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -196,17 +196,25 @@ export default function AgedPipePopupPage() {
|
||||
window.close();
|
||||
};
|
||||
|
||||
const getCellClassName = (_row: AgedPipeRow, headId: string) => {
|
||||
if (headId === "mqltCd") {
|
||||
return "break-all whitespace-normal";
|
||||
}
|
||||
|
||||
return "whitespace-normal";
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">노후관로 현황</h1>
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">노후관로 현황</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="xs"
|
||||
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
|
||||
className="sn-popup-header-button"
|
||||
onClick={handleMinimize}
|
||||
>
|
||||
최소화
|
||||
@@ -215,17 +223,17 @@ export default function AgedPipePopupPage() {
|
||||
</header>
|
||||
|
||||
{downloadProgressText ? (
|
||||
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
|
||||
<div className="sn-popup-success-bar">
|
||||
{downloadProgressText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
|
||||
<label className="block md:max-w-[160px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년도</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">경과년도</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -244,10 +252,10 @@ export default function AgedPipePopupPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[320px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
@@ -266,10 +274,10 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[320px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
>
|
||||
@@ -282,12 +290,12 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<div className="sn-popup-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
@@ -295,8 +303,8 @@ export default function AgedPipePopupPage() {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
다운로드
|
||||
@@ -304,19 +312,65 @@ export default function AgedPipePopupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiTable<AgedPipeRow>
|
||||
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
pageSize={10}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
/>
|
||||
<div className="aged-pipe-popup-table min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<AgedPipeRow>
|
||||
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
pageSize={10}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
cellClassName={getCellClassName}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
className="!m-0 overflow-x-hidden"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<style jsx global>{`
|
||||
.aged-pipe-popup-table .card-header {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .card-header h3 {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .card {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui th {
|
||||
padding: 0.8rem 0.45rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui td {
|
||||
padding: 0.85rem 0.4rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(2),
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(3),
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(8) {
|
||||
word-break: keep-all;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,9 +59,9 @@ export const AGED_PIPE_LIST_HEADLIST = [
|
||||
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
|
||||
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
|
||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 14 },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 12 },
|
||||
{ id: "mqltCd", title: "재질", widthPercent: 18 },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
|
||||
];
|
||||
|
||||
export const AGED_PIPE_EXCEL_FIELDS = [
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
ChartColumnBig,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
Search,
|
||||
@@ -16,36 +15,95 @@ import {
|
||||
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
|
||||
|
||||
type Props = {
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => void;
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
||||
onOpenInfoSearch: () => void;
|
||||
onToggleGisEdit?: () => void;
|
||||
isGisEditActive?: boolean;
|
||||
isInfoSearchActive?: boolean;
|
||||
initialActiveKey?: WindowPopupKey | null;
|
||||
};
|
||||
|
||||
type MenuIconProps = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-primary" : "text-label"}`}
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M12 2.8C9.5 6.5 6.7 9.8 6.7 13.3A5.3 5.3 0 0 0 12 18.6a5.3 5.3 0 0 0 5.3-5.3c0-3.5-2.8-6.8-5.3-10.5Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 13.4c0-1.2.8-2.6 1.9-4.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
key: WindowPopupKey | "infoSearch";
|
||||
label: string;
|
||||
icon: typeof ChartColumnBig;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
renderIcon?: (props: MenuIconProps) => ReactNode;
|
||||
}> = [
|
||||
{ key: "infoSearch", label: "정보검색", icon: Search },
|
||||
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
|
||||
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
|
||||
{ key: "floodHistoryPopup", label: "침수발생이력", icon: History },
|
||||
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
|
||||
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
|
||||
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
|
||||
{ key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon },
|
||||
{ key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig },
|
||||
];
|
||||
|
||||
export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) {
|
||||
export default function AnalysisPopupMenu({
|
||||
onOpenPopup,
|
||||
onOpenInfoSearch,
|
||||
onToggleGisEdit,
|
||||
isGisEditActive = false,
|
||||
isInfoSearchActive = false,
|
||||
initialActiveKey = null,
|
||||
}: Props) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
|
||||
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(initialActiveKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
||||
setActiveKey(null);
|
||||
}
|
||||
}, [activeKey, isGisEditActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInfoSearchActive && activeKey === "infoSearch") {
|
||||
setActiveKey(null);
|
||||
}
|
||||
}, [activeKey, isInfoSearchActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialActiveKey) return;
|
||||
setActiveKey((currentKey) => currentKey ?? initialActiveKey);
|
||||
}, [initialActiveKey]);
|
||||
|
||||
return (
|
||||
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
|
||||
<div className="relative">
|
||||
{isOpen ? (
|
||||
<div className="absolute right-[28px] w-[132px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
|
||||
<div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]">
|
||||
<div className="absolute right-[28px] w-[154px] overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
|
||||
<div className="flex h-[36px] items-center gap-2 border-b border-border bg-secondary px-[10px] text-[13px] font-bold text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" />
|
||||
<PanelsTopLeft className="h-4 w-4 text-primary" />
|
||||
<span>분석도구</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +111,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
<div className="flex flex-col gap-1 p-[6px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeKey === item.key;
|
||||
const isActive = item.key === "gisEditPopup"
|
||||
? isGisEditActive
|
||||
: activeKey === item.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -61,10 +121,16 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
type="button"
|
||||
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
|
||||
isActive
|
||||
? "border-[#b9dcff] bg-[#eaf4ff] font-bold text-[#0f5fa8] before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-[#1e88e5]"
|
||||
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
|
||||
? "border-primary bg-secondary font-bold text-primary before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
|
||||
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (item.key === "gisEditPopup" && onToggleGisEdit) {
|
||||
setActiveKey(item.key);
|
||||
onToggleGisEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveKey(item.key);
|
||||
|
||||
if (item.key === "infoSearch") {
|
||||
@@ -72,15 +138,30 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenPopup(item.key);
|
||||
const popupWindow = onOpenPopup(item.key);
|
||||
if (!popupWindow) {
|
||||
setActiveKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const closeWatcher = window.setInterval(() => {
|
||||
if (!popupWindow.closed) return;
|
||||
window.clearInterval(closeWatcher);
|
||||
setActiveKey((currentKey) =>
|
||||
currentKey === item.key ? null : currentKey,
|
||||
);
|
||||
}, 300);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={`h-[15px] w-[15px] shrink-0 ${
|
||||
isActive ? "text-[#1e88e5]" : "text-[#64748b]"
|
||||
}`}
|
||||
/>
|
||||
<span className="break-keep whitespace-normal">{item.label}</span>
|
||||
{item.renderIcon ? item.renderIcon({ isActive }) : null}
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={`h-[15px] w-[15px] shrink-0 ${
|
||||
isActive ? "text-primary" : "text-label"
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -90,7 +171,7 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#64748b] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] hover:text-[#1e88e5]"
|
||||
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-border bg-background text-label shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-accent hover:text-primary"
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
|
||||
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 하수관로
|
||||
export const MsSwagePplCol = [
|
||||
{ id: 'fclt_mng_no', title: '시설관리번호' },
|
||||
{ id: 'mngInstNm', title: '관리기관' },
|
||||
{ id: 'inst_mng_no', title: '기관관리번호' },
|
||||
{ id: 'flowDirectionBasis', title: '유하방향 근거' },
|
||||
{ id: 'instl_ymd', title: '설치일자' },
|
||||
{ id: 'elapsedYears', title: '경과년도' },
|
||||
{ id: 'mapsht_no', title: '도엽번호' },
|
||||
@@ -33,6 +35,7 @@ export const MsSwagePplCol2 = [
|
||||
{ id: 'rowNumber', title: '일련\n번호' },
|
||||
{ id: 'fcltMngNo', title: '시설\n관리번호' },
|
||||
{ id: 'instMngNo', title: '기관\n관리번호' },
|
||||
{ id: 'flowDirectionBasis', title: '유하방향\n근거' },
|
||||
{ id: 'ctpvNm', title: '시도' },
|
||||
{ id: 'sggNm', title: '시군구' },
|
||||
{ id: "swtrmt_zone_nm", title: "처리구역" },
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes";
|
||||
|
||||
type Props = {
|
||||
detail: FloodHistoryItem;
|
||||
onClose: () => void;
|
||||
position: {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DetailTab = "year" | "disaster" | "rainfall";
|
||||
|
||||
const renderRows = (rows: Array<[string, string]>) => (
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[42%]" />
|
||||
<col className="w-[58%]" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{rows.map(([label, value], index) => (
|
||||
<tr key={`${label}-${index}`}>
|
||||
<th className="border border-[#d8e3ee] bg-[#f8fbfe] px-3 py-[7px] text-left text-[1.02rem] font-semibold text-[#36506b]">
|
||||
{label}
|
||||
</th>
|
||||
<td className="border border-[#d8e3ee] bg-white px-3 py-[7px] text-[1.02rem] text-[#1f2937]">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) {
|
||||
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("year");
|
||||
|
||||
const yearRows = useMemo(
|
||||
() => [
|
||||
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수년도", `${detail.floodYear}년`],
|
||||
["등급", detail.gradeLabel],
|
||||
["침수구역", detail.districtName],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const disasterRows = useMemo(
|
||||
() => [
|
||||
["침수재해명", detail.disasterName],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수원인상세명", detail.causeDetailName],
|
||||
["침수시작년월일", detail.startDate],
|
||||
["침수종료년월일", detail.endDate],
|
||||
["침수시작시간", detail.startTime],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const rainfallRows = useMemo(
|
||||
() => [
|
||||
["침수시작일자", detail.startDate],
|
||||
["침수종료일자", detail.endDate],
|
||||
["침수시작시간", detail.startTime],
|
||||
["침수종료시간", detail.endTime],
|
||||
["침수시간", detail.floodHour],
|
||||
["강우시작시간", detail.rainStartTime],
|
||||
["강우종료시간", detail.rainEndTime],
|
||||
["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`],
|
||||
["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`],
|
||||
["침수시간", detail.floodHour],
|
||||
["침수원인상세코드", detail.causeDetailCode],
|
||||
["피해금액", detail.damageAmount],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const currentRows = activeTab === "year"
|
||||
? yearRows
|
||||
: activeTab === "disaster"
|
||||
? disasterRows
|
||||
: rainfallRows;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-[#cfdbe8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.18)]"
|
||||
style={{
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[28px] items-center justify-between border-b border-[#d8e3ee] bg-[linear-gradient(180deg,#ffffff_0%,#f6faff_100%)] px-[10px]">
|
||||
<div className="text-[10px] font-bold tracking-[-0.01em] text-[#243b53]">상세정보</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[4px] px-[4px] text-[13px] leading-none text-[#7b8ba1] transition hover:bg-[#eef5fb] hover:text-[#35597d]"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[#d8e3ee] bg-[#f8fbfe] px-[6px] pt-[6px]">
|
||||
<div className="flex gap-[4px]">
|
||||
{[
|
||||
["year", "년도탭"],
|
||||
["disaster", "재해명탭"],
|
||||
["rainfall", "침수발생강우탭"],
|
||||
].map(([tabId, label]) => {
|
||||
const isActive = activeTab === tabId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tabId}
|
||||
type="button"
|
||||
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
|
||||
isActive
|
||||
? "border-[#b8d4f0] border-b-white bg-white text-[#1f5d99]"
|
||||
: "border-[#d8e3ee] bg-[#f3f7fb] text-[#5f7388] hover:bg-[#eef5fb]"
|
||||
}`}
|
||||
onClick={() => setActiveTab(tabId as DetailTab)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-[6px]">
|
||||
<div className="overflow-hidden rounded-[6px] border border-[#d8e3ee] bg-white">
|
||||
{renderRows(currentRows)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import useStore from "@/store/useStore";
|
||||
import {
|
||||
buildFloodHistoryYearOptions,
|
||||
buildFloodHistoryTableRows,
|
||||
FLOOD_HISTORY_DEFAULT_FILTERS,
|
||||
FLOOD_HISTORY_DOWNLOAD_HEADERS,
|
||||
FLOOD_HISTORY_ITEMS,
|
||||
FLOOD_HISTORY_PAGE_SIZE,
|
||||
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
formatFloodArea,
|
||||
formatFloodDepth,
|
||||
normalizeFloodHistoryFilters,
|
||||
type FloodHistoryFilters,
|
||||
type FloodHistoryItem,
|
||||
} from "./floodHistoryTypes";
|
||||
|
||||
const resolveOptionText = (
|
||||
code: string,
|
||||
options: Array<{ text: string; val: string | number }>,
|
||||
prefixLength?: number,
|
||||
) => {
|
||||
const normalized = String(code ?? "").trim();
|
||||
if (!normalized || normalized === "0") return "";
|
||||
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
|
||||
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
|
||||
};
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ id: "rowNo", title: "NO", width: "64px", align: "center" },
|
||||
{ id: "ctpvNm", title: "시도", width: "90px", align: "center" },
|
||||
{ id: "sggNm", title: "시군구", width: "100px", align: "center" },
|
||||
{ id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" },
|
||||
{ id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" },
|
||||
{ id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" },
|
||||
{ id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" },
|
||||
{ id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" },
|
||||
{ id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" },
|
||||
{ id: "disasterName", title: "침수재해명", width: "190px", align: "left" },
|
||||
{ id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" },
|
||||
{ id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" },
|
||||
{ id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" },
|
||||
{ id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" },
|
||||
{ id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" },
|
||||
{ id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" },
|
||||
] as const;
|
||||
|
||||
const buildDownloadRows = (rows: FloodHistoryItem[]) =>
|
||||
rows.map((row, index) => ({
|
||||
rowNo: index + 1,
|
||||
ctpvNm: row.ctpvNm,
|
||||
sggNm: row.sggNm,
|
||||
disasterName: row.disasterName,
|
||||
exmnYearDisplay: `${row.exmnYear}년`,
|
||||
avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(row.floodAreaHa),
|
||||
causeDetailDisplay: row.causeDetailName,
|
||||
startDateDisplay: row.startDate,
|
||||
endDateDisplay: row.endDate,
|
||||
startTimeDisplay: row.startTime,
|
||||
endTimeDisplay: row.endTime,
|
||||
causeCodeDisplay: row.causeDetailCode,
|
||||
districtNameDisplay: row.districtName,
|
||||
damageAmountDisplay: row.damageAmount,
|
||||
floodHourDisplay: row.floodHour,
|
||||
floodYearDisplay: `${row.floodYear}년`,
|
||||
}));
|
||||
|
||||
export default function FloodHistoryPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
// 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다.
|
||||
const [filters, setFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
|
||||
// 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다.
|
||||
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedHistoryId, setSelectedHistoryId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const floodYearOptions = useMemo(
|
||||
() => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS),
|
||||
[],
|
||||
);
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
|
||||
[filters.ctpvCd, sgg],
|
||||
);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalized = normalizeFloodHistoryFilters(appliedFilters);
|
||||
const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2);
|
||||
const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5);
|
||||
const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR");
|
||||
|
||||
return FLOOD_HISTORY_ITEMS.filter((item) => {
|
||||
if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false;
|
||||
if (selectedSggName && item.sggNm !== selectedSggName) return false;
|
||||
if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false;
|
||||
if (
|
||||
disasterToken &&
|
||||
!item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) &&
|
||||
!item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((left, right) => {
|
||||
if (left.floodYear !== right.floodYear) {
|
||||
return Number(right.floodYear) - Number(left.floodYear);
|
||||
}
|
||||
return left.disasterName.localeCompare(right.disasterName, "ko");
|
||||
});
|
||||
}, [appliedFilters, ctpv, sgg]);
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE;
|
||||
const end = start + FLOOD_HISTORY_PAGE_SIZE;
|
||||
return buildFloodHistoryTableRows(
|
||||
filteredRows.slice(start, end),
|
||||
currentPage,
|
||||
FLOOD_HISTORY_PAGE_SIZE,
|
||||
);
|
||||
}, [currentPage, filteredRows]);
|
||||
|
||||
const handleSearch = () => {
|
||||
const normalized = normalizeFloodHistoryFilters(filters);
|
||||
|
||||
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
|
||||
alert("시도를 먼저 선택하세요.");
|
||||
return;
|
||||
}
|
||||
if (normalized.floodYear && normalized.floodYear.length !== 4) {
|
||||
alert("침수발생년도는 4자리 연도로 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAppliedFilters(normalized);
|
||||
setCurrentPage(1);
|
||||
setSelectedHistoryId("");
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (filteredRows.length === 0) {
|
||||
alert("다운로드할 침수발생이력 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(filteredRows) as unknown as JSON[],
|
||||
fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
sheetNameArray: ["침수흔적도"],
|
||||
headerRange: "A1:Q1",
|
||||
headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (row: FloodHistoryItem) => {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
openDetail: false,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: FloodHistoryItem) => {
|
||||
setSelectedHistoryId(row.historyId);
|
||||
// popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다.
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
openDetail: false,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">침수흔적도 목록</h1>
|
||||
<div />
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section">
|
||||
<div className="sn-popup-filter-panel-compact">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpv.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggCd: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">침수발생년도</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.floodYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
floodYear: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{floodYearOptions.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}년
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">침수재해명</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={filters.disasterName}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
disasterName: event.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions md:pr-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="card !m-[1.2rem] !mt-0 min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
||||
<Table variant="secondary" className="w-max min-w-full">
|
||||
<colgroup>
|
||||
{GRID_COLUMNS.map((head) => (
|
||||
<col key={head.id} style={{ width: head.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader className="font-bold">
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">NO</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">시도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">시군구</TableHead>
|
||||
<TableHead colSpan={4} className="whitespace-nowrap">강우 분석정보</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수재해명</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수년도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수시간</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수지구명</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세코드</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">평균침수위</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수면적</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">조사년도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">피해금액</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="whitespace-nowrap">침수시작일자</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수종료일자</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수시작시간</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수종료시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{pagedRows.length > 0 ? (
|
||||
pagedRows.map((row) => {
|
||||
const isSelected = selectedHistoryId === row.historyId;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.historyId}
|
||||
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row-hover"}`}
|
||||
onClick={() => handleRowClick(row)}
|
||||
onDoubleClick={() => handleRowDoubleClick(row)}
|
||||
>
|
||||
<TableCell>{row.rowNo}</TableCell>
|
||||
<TableCell>{row.ctpvNm}</TableCell>
|
||||
<TableCell>{row.sggNm}</TableCell>
|
||||
<TableCell>{row.startDateDisplay}</TableCell>
|
||||
<TableCell>{row.endDateDisplay}</TableCell>
|
||||
<TableCell>{row.startTimeDisplay}</TableCell>
|
||||
<TableCell>{row.endTimeDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.disasterName}</TableCell>
|
||||
<TableCell>{row.floodYearDisplay}</TableCell>
|
||||
<TableCell>{row.floodHourDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.districtNameDisplay}</TableCell>
|
||||
<TableCell>{row.causeCodeDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.causeDetailDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.avgFloodDepthDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.floodAreaDisplay}</TableCell>
|
||||
<TableCell>{row.exmnYearDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.damageAmountDisplay}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={GRID_COLUMNS.length} className="sn-popup-empty-cell h-24 text-center">
|
||||
조회된 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredRows.length > 0 ? (
|
||||
<UiPageNations
|
||||
total={filteredRows.length}
|
||||
cur={currentPage}
|
||||
pageSize={FLOOD_HISTORY_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="sn-popup-help-text">
|
||||
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup";
|
||||
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
|
||||
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLOOD_HISTORY_PAGE_SIZE = 10;
|
||||
|
||||
export type FloodHistoryFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
floodYear: string;
|
||||
disasterName: string;
|
||||
};
|
||||
|
||||
export type FloodHistoryDepthZone = {
|
||||
zoneId: string;
|
||||
depthLabel: string;
|
||||
color: string;
|
||||
ring: [number, number][];
|
||||
};
|
||||
|
||||
export type FloodHistoryItem = {
|
||||
historyId: string;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
floodYear: string;
|
||||
exmnYear: string;
|
||||
disasterName: string;
|
||||
districtName: string;
|
||||
floodAreaHa: number;
|
||||
avgFloodDepthM: number;
|
||||
floodDepthM: number;
|
||||
gradeLabel: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
floodHour: string;
|
||||
causeDetailCode: string;
|
||||
causeDetailName: string;
|
||||
damageAmount: string;
|
||||
rainStartTime: string;
|
||||
rainEndTime: string;
|
||||
totalRainfallMm: number;
|
||||
rainfallIntensityMmHr: number;
|
||||
areaRing: [number, number][];
|
||||
depthZones: FloodHistoryDepthZone[];
|
||||
};
|
||||
|
||||
export type FloodHistoryTableRow = FloodHistoryItem & {
|
||||
rowNo: number;
|
||||
exmnYearDisplay: string;
|
||||
avgFloodDepthDisplay: string;
|
||||
floodAreaDisplay: string;
|
||||
causeDetailDisplay: string;
|
||||
startDateDisplay: string;
|
||||
endDateDisplay: string;
|
||||
startTimeDisplay: string;
|
||||
endTimeDisplay: string;
|
||||
causeCodeDisplay: string;
|
||||
districtNameDisplay: string;
|
||||
damageAmountDisplay: string;
|
||||
floodHourDisplay: string;
|
||||
floodYearDisplay: string;
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
floodYear: "",
|
||||
disasterName: "",
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
disasterName: "침수재해명",
|
||||
exmnYearDisplay: "조사년도",
|
||||
avgFloodDepthDisplay: "평균침수위",
|
||||
floodAreaDisplay: "침수면적",
|
||||
causeDetailDisplay: "침수원인상세",
|
||||
startDateDisplay: "침수시작일자",
|
||||
endDateDisplay: "침수종료일자",
|
||||
startTimeDisplay: "침수시작시간",
|
||||
endTimeDisplay: "침수종료시간",
|
||||
causeCodeDisplay: "침수원인상세코드",
|
||||
districtNameDisplay: "침수지구명",
|
||||
damageAmountDisplay: "피해금액",
|
||||
floodHourDisplay: "침수시간",
|
||||
floodYearDisplay: "침수년도",
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [
|
||||
{ color: "#fff27a", label: "0.0~0.5m" },
|
||||
{ color: "#c2f13d", label: "0.5~1.0m" },
|
||||
{ color: "#67d8ff", label: "1.0~2.0m" },
|
||||
{ color: "#8f8bff", label: "2.0~5.0m" },
|
||||
{ color: "#f2528b", label: "5.0m 이상" },
|
||||
] as const;
|
||||
|
||||
const toRadian = (degree: number) => (degree * Math.PI) / 180;
|
||||
|
||||
const createIrregularRing = ({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX,
|
||||
radiusY,
|
||||
scales,
|
||||
rotationDeg = 0,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
scales: number[];
|
||||
rotationDeg?: number;
|
||||
}): [number, number][] => {
|
||||
const rotation = toRadian(rotationDeg);
|
||||
const points = scales.map((scale, index) => {
|
||||
const angle = rotation + (Math.PI * 2 * index) / scales.length;
|
||||
const jitterX = radiusX * scale * Math.cos(angle);
|
||||
const jitterY = radiusY * scale * Math.sin(angle);
|
||||
return [centerX + jitterX, centerY + jitterY] as [number, number];
|
||||
});
|
||||
|
||||
return [...points, points[0]];
|
||||
};
|
||||
|
||||
const createDepthZones = (
|
||||
historyId: string,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): FloodHistoryDepthZone[] => {
|
||||
return [
|
||||
{
|
||||
zoneId: `${historyId}-depth-1`,
|
||||
depthLabel: "0.0~0.5m",
|
||||
color: "#fff27a",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 5200,
|
||||
centerY: centerY - 2600,
|
||||
radiusX: 3600,
|
||||
radiusY: 2900,
|
||||
rotationDeg: -18,
|
||||
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-2`,
|
||||
depthLabel: "0.5~1.0m",
|
||||
color: "#c2f13d",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 2600,
|
||||
centerY: centerY - 1200,
|
||||
radiusX: 4300,
|
||||
radiusY: 3600,
|
||||
rotationDeg: 12,
|
||||
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-3`,
|
||||
depthLabel: "1.0~2.0m",
|
||||
color: "#67d8ff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 600,
|
||||
centerY: centerY + 2600,
|
||||
radiusX: 5600,
|
||||
radiusY: 4700,
|
||||
rotationDeg: 8,
|
||||
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-4`,
|
||||
depthLabel: "2.0~5.0m",
|
||||
color: "#8f8bff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 4200,
|
||||
centerY: centerY + 5200,
|
||||
radiusX: 3200,
|
||||
radiusY: 2500,
|
||||
rotationDeg: 26,
|
||||
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-5`,
|
||||
depthLabel: "5.0m 이상",
|
||||
color: "#f2528b",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 600,
|
||||
centerY: centerY - 5200,
|
||||
radiusX: 1500,
|
||||
radiusY: 1350,
|
||||
rotationDeg: -10,
|
||||
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const createHistory = (
|
||||
item: Omit<FloodHistoryItem, "areaRing" | "depthZones"> & {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
): FloodHistoryItem => {
|
||||
const { centerX, centerY, width, height, ...detail } = item;
|
||||
|
||||
return {
|
||||
...detail,
|
||||
areaRing: createIrregularRing({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX: width / 2,
|
||||
radiusY: height / 2,
|
||||
rotationDeg: 9,
|
||||
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
|
||||
}),
|
||||
depthZones: createDepthZones(detail.historyId, centerX, centerY),
|
||||
};
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [
|
||||
createHistory({
|
||||
historyId: "FH-2024-001",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "중구",
|
||||
floodYear: "2024",
|
||||
exmnYear: "2025",
|
||||
disasterName: "중앙동 저지대 침수",
|
||||
districtName: "중앙동 저지대",
|
||||
floodAreaHa: 1.8,
|
||||
avgFloodDepthM: 0.62,
|
||||
floodDepthM: 0.62,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2024-07-18",
|
||||
endDate: "2024-07-18",
|
||||
startTime: "14:10",
|
||||
endTime: "18:20",
|
||||
floodHour: "4.2시간",
|
||||
causeDetailCode: "FC-01",
|
||||
causeDetailName: "집중강우",
|
||||
damageAmount: "120,000,000원",
|
||||
rainStartTime: "13:50",
|
||||
rainEndTime: "18:20",
|
||||
totalRainfallMm: 91,
|
||||
rainfallIntensityMmHr: 31,
|
||||
centerX: 1122000,
|
||||
centerY: 1705000,
|
||||
width: 18000,
|
||||
height: 14000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2023-002",
|
||||
ctpvNm: "경상남도",
|
||||
sggNm: "창원시",
|
||||
floodYear: "2023",
|
||||
exmnYear: "2024",
|
||||
disasterName: "팔용동 공단 배수지연 침수",
|
||||
districtName: "팔용동 공단",
|
||||
floodAreaHa: 4.8,
|
||||
avgFloodDepthM: 1.92,
|
||||
floodDepthM: 1.92,
|
||||
gradeLabel: "2등급",
|
||||
startDate: "2023-08-11",
|
||||
endDate: "2023-08-11",
|
||||
startTime: "09:40",
|
||||
endTime: "15:10",
|
||||
floodHour: "5.5시간",
|
||||
causeDetailCode: "FC-04",
|
||||
causeDetailName: "배수시설 처리능력 초과",
|
||||
damageAmount: "340,000,000원",
|
||||
rainStartTime: "08:55",
|
||||
rainEndTime: "15:05",
|
||||
totalRainfallMm: 138,
|
||||
rainfallIntensityMmHr: 42,
|
||||
centerX: 1081000,
|
||||
centerY: 1778000,
|
||||
width: 25000,
|
||||
height: 17000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2022-003",
|
||||
ctpvNm: "울산광역시",
|
||||
sggNm: "남구",
|
||||
floodYear: "2022",
|
||||
exmnYear: "2023",
|
||||
disasterName: "태화강 제내지 도로 침수",
|
||||
districtName: "여천동 하류부",
|
||||
floodAreaHa: 3.1,
|
||||
avgFloodDepthM: 1.15,
|
||||
floodDepthM: 1.15,
|
||||
gradeLabel: "2등급",
|
||||
startDate: "2022-09-06",
|
||||
endDate: "2022-09-06",
|
||||
startTime: "07:20",
|
||||
endTime: "12:40",
|
||||
floodHour: "5.3시간",
|
||||
causeDetailCode: "FC-02",
|
||||
causeDetailName: "하천 수위 상승",
|
||||
damageAmount: "210,000,000원",
|
||||
rainStartTime: "06:30",
|
||||
rainEndTime: "11:50",
|
||||
totalRainfallMm: 117,
|
||||
rainfallIntensityMmHr: 37,
|
||||
centerX: 1168000,
|
||||
centerY: 1811000,
|
||||
width: 22000,
|
||||
height: 16000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2021-004",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "서구",
|
||||
floodYear: "2021",
|
||||
exmnYear: "2022",
|
||||
disasterName: "서대신동 급경사지 유출 침수",
|
||||
districtName: "서대신동 일원",
|
||||
floodAreaHa: 2.4,
|
||||
avgFloodDepthM: 0.85,
|
||||
floodDepthM: 0.85,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2021-07-07",
|
||||
endDate: "2021-07-07",
|
||||
startTime: "16:00",
|
||||
endTime: "20:10",
|
||||
floodHour: "4.1시간",
|
||||
causeDetailCode: "FC-03",
|
||||
causeDetailName: "우수관로 월류",
|
||||
damageAmount: "175,000,000원",
|
||||
rainStartTime: "15:20",
|
||||
rainEndTime: "19:50",
|
||||
totalRainfallMm: 104,
|
||||
rainfallIntensityMmHr: 29,
|
||||
centerX: 1111000,
|
||||
centerY: 1709000,
|
||||
width: 20000,
|
||||
height: 14500,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2020-005",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "영도구",
|
||||
floodYear: "2020",
|
||||
exmnYear: "2021",
|
||||
disasterName: "청학동 해안도로 월파 침수",
|
||||
districtName: "청학동 해안부",
|
||||
floodAreaHa: 1.2,
|
||||
avgFloodDepthM: 0.48,
|
||||
floodDepthM: 0.48,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2020-10-22",
|
||||
endDate: "2020-10-22",
|
||||
startTime: "03:10",
|
||||
endTime: "06:30",
|
||||
floodHour: "3.3시간",
|
||||
causeDetailCode: "FC-06",
|
||||
causeDetailName: "해안 월파",
|
||||
damageAmount: "82,000,000원",
|
||||
rainStartTime: "02:40",
|
||||
rainEndTime: "05:50",
|
||||
totalRainfallMm: 55,
|
||||
rainfallIntensityMmHr: 18,
|
||||
centerX: 1129000,
|
||||
centerY: 1695000,
|
||||
width: 15000,
|
||||
height: 12000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2019-006",
|
||||
ctpvNm: "경상남도",
|
||||
sggNm: "김해시",
|
||||
floodYear: "2019",
|
||||
exmnYear: "2020",
|
||||
disasterName: "장유 배수분구 내수 침수",
|
||||
districtName: "장유 배수분구",
|
||||
floodAreaHa: 2.9,
|
||||
avgFloodDepthM: 0.96,
|
||||
floodDepthM: 0.96,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2019-09-21",
|
||||
endDate: "2019-09-21",
|
||||
startTime: "11:30",
|
||||
endTime: "16:15",
|
||||
floodHour: "4.8시간",
|
||||
causeDetailCode: "FC-05",
|
||||
causeDetailName: "배수펌프 지연 가동",
|
||||
damageAmount: "143,000,000원",
|
||||
rainStartTime: "10:45",
|
||||
rainEndTime: "15:50",
|
||||
totalRainfallMm: 88,
|
||||
rainfallIntensityMmHr: 24,
|
||||
centerX: 1098000,
|
||||
centerY: 1734000,
|
||||
width: 21000,
|
||||
height: 15000,
|
||||
}),
|
||||
];
|
||||
|
||||
export const normalizeFloodHistoryFilters = (
|
||||
filters: FloodHistoryFilters,
|
||||
): FloodHistoryFilters => ({
|
||||
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
|
||||
sggCd: String(filters.sggCd ?? "0") || "0",
|
||||
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
|
||||
disasterName: String(filters.disasterName ?? "").trim(),
|
||||
});
|
||||
|
||||
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
|
||||
|
||||
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
|
||||
|
||||
export const buildFloodHistoryTableRows = (
|
||||
items: FloodHistoryItem[],
|
||||
page: number,
|
||||
pageSize = FLOOD_HISTORY_PAGE_SIZE,
|
||||
): FloodHistoryTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: (page - 1) * pageSize + index + 1,
|
||||
exmnYearDisplay: `${item.exmnYear}년`,
|
||||
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
|
||||
causeDetailDisplay: item.causeDetailName,
|
||||
startDateDisplay: item.startDate,
|
||||
endDateDisplay: item.endDate,
|
||||
startTimeDisplay: item.startTime,
|
||||
endTimeDisplay: item.endTime,
|
||||
causeCodeDisplay: item.causeDetailCode,
|
||||
districtNameDisplay: item.districtName,
|
||||
damageAmountDisplay: item.damageAmount,
|
||||
floodHourDisplay: item.floodHour,
|
||||
floodYearDisplay: `${item.floodYear}년`,
|
||||
}));
|
||||
|
||||
export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) =>
|
||||
Array.from(new Set(items.map((item) => item.floodYear)))
|
||||
.sort((left, right) => Number(right) - Number(left));
|
||||
|
||||
const resolveFloodHistoryNodeName = (node: any) =>
|
||||
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
|
||||
|
||||
const resolveFloodHistoryLayerType = (node: any) =>
|
||||
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
|
||||
|
||||
export const isFloodHistoryNode = (node: any) => {
|
||||
const name = resolveFloodHistoryNodeName(node);
|
||||
const layerType = resolveFloodHistoryLayerType(node);
|
||||
return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE;
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
|
||||
import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes";
|
||||
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
|
||||
|
||||
type Props = {
|
||||
change: GisEditChange;
|
||||
featureData: any;
|
||||
onClose: () => void;
|
||||
onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void;
|
||||
};
|
||||
|
||||
type MngInstOption = {
|
||||
mngInstCd: string;
|
||||
lwrInstNm: string;
|
||||
};
|
||||
|
||||
const EMPTY_SELECT_OPTION = { value: "", label: "선택" };
|
||||
|
||||
const PIPE_BASIC_INFO_FIELDS = [
|
||||
{ id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true },
|
||||
{ id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true },
|
||||
{ id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true },
|
||||
{ id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" },
|
||||
{ id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" },
|
||||
{ id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" },
|
||||
{ id: "prlgVl", sourceId: "prlg_vl", title: "연장(m)", type: "input" },
|
||||
{
|
||||
id: "usgCd",
|
||||
sourceId: "usg_cd",
|
||||
title: "용도",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mqltCd",
|
||||
sourceId: "mqlt_cd",
|
||||
title: "재질",
|
||||
type: "select",
|
||||
options: [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
|
||||
],
|
||||
},
|
||||
{ id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" },
|
||||
{ id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" },
|
||||
{ id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" },
|
||||
{ id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" },
|
||||
{ id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" },
|
||||
{ id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" },
|
||||
{ id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" },
|
||||
{ id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" },
|
||||
{ id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" },
|
||||
{ id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" },
|
||||
];
|
||||
|
||||
const PIPE_INPUT_INFO_FIELDS = [
|
||||
{ id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] },
|
||||
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
|
||||
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
|
||||
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
|
||||
{ id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] },
|
||||
{ id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] },
|
||||
{ id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] },
|
||||
];
|
||||
|
||||
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id);
|
||||
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id);
|
||||
|
||||
const toEditableText = (value: unknown) => {
|
||||
const text = String(value ?? "").trim();
|
||||
return text === "-" ? "" : text;
|
||||
};
|
||||
|
||||
const isInvalidPipeLookupKey = (value: string) =>
|
||||
!value || value === "-" || /^NEW-/i.test(value) || /^(null|undefined)$/i.test(value);
|
||||
|
||||
const toDateInputValue = (value: unknown) => {
|
||||
const text = toEditableText(value).replaceAll(".", "").replaceAll("-", "");
|
||||
if (!/^\d{8}$/.test(text)) return "";
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
};
|
||||
|
||||
const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", "");
|
||||
|
||||
const getObjectValue = (values: Record<string, any>, field: { id: string; sourceId?: string }) =>
|
||||
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
|
||||
|
||||
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
|
||||
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
|
||||
if (Object.prototype.hasOwnProperty.call(values, fieldId)) {
|
||||
acc[fieldId] = values[fieldId];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const normalizeBasicFormValues = (values: Record<string, any>) => {
|
||||
const nextValues = pickFields(values, PIPE_VISIBLE_BASIC_FIELD_IDS);
|
||||
if (Object.prototype.hasOwnProperty.call(nextValues, "instlYmd")) {
|
||||
nextValues.instlYmd = toDateInputValue(nextValues.instlYmd);
|
||||
}
|
||||
return nextValues;
|
||||
};
|
||||
|
||||
const buildBaseFeatureValues = (featureData: any) => {
|
||||
const resolveUsageCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
const resolveMaterialCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
const sourceValues = featureData?.values_ ?? featureData ?? {};
|
||||
return PIPE_BASIC_INFO_FIELDS.reduce<Record<string, any>>((acc, field) => {
|
||||
const rawValue = getObjectValue(sourceValues, field);
|
||||
if (field.id === "usgCd") {
|
||||
acc[field.id] = resolveUsageCode(rawValue);
|
||||
return acc;
|
||||
}
|
||||
if (field.id === "mqltCd") {
|
||||
acc[field.id] = resolveMaterialCode(rawValue);
|
||||
return acc;
|
||||
}
|
||||
if (field.id === "instlYmd") {
|
||||
acc[field.id] = toDateInputValue(rawValue);
|
||||
return acc;
|
||||
}
|
||||
acc[field.id] = toEditableText(rawValue);
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getPipeUsageLabel = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw;
|
||||
};
|
||||
|
||||
const buildInitialInputDraft = (change: GisEditChange) => ({
|
||||
instlMbdyCd: toEditableText(change.basicInfo?.instlMbdyCd ?? ""),
|
||||
swtrmtZoneNm: toEditableText(change.basicInfo?.swtrmtZoneNm ?? ""),
|
||||
mltvZoneNm: toEditableText(change.basicInfo?.mltvZoneNm ?? ""),
|
||||
mltvDtdvNm: toEditableText(change.basicInfo?.mltvDtdvNm ?? ""),
|
||||
lineCd: toEditableText(change.basicInfo?.lineCd ?? ""),
|
||||
floCd: toEditableText(change.basicInfo?.floCd ?? ""),
|
||||
layCd: toEditableText(change.basicInfo?.layCd ?? ""),
|
||||
});
|
||||
|
||||
export default function GisEditPipeSessionPopup({
|
||||
change,
|
||||
featureData,
|
||||
onClose,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const isNewPipe = change.editType === "ADD";
|
||||
const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]);
|
||||
const [basicInfoTableData, setBasicInfoTableData] = useState<Record<string, any>>({});
|
||||
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
|
||||
buildInitialInputDraft(change),
|
||||
);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchMngInstOptions = async () => {
|
||||
try {
|
||||
const result = await getData("v1/fcltppl/mngInsts");
|
||||
if (!isMounted) return;
|
||||
setMngInstOptions(Array.isArray(result) ? result : []);
|
||||
} catch {
|
||||
if (isMounted) setMngInstOptions([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMngInstOptions();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mngInstSelectOptions = useMemo(
|
||||
() => {
|
||||
const sortedOptions = [...mngInstOptions].sort((a, b) =>
|
||||
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
|
||||
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
|
||||
"ko",
|
||||
),
|
||||
);
|
||||
return [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...sortedOptions.map((option) => ({
|
||||
value: String(option.mngInstCd ?? "").trim(),
|
||||
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
|
||||
})),
|
||||
];
|
||||
},
|
||||
[mngInstOptions],
|
||||
);
|
||||
|
||||
const basicInfoFields = useMemo(
|
||||
() =>
|
||||
PIPE_BASIC_INFO_FIELDS.map((field) =>
|
||||
field.id === "mngInstCd"
|
||||
? { ...field, options: mngInstSelectOptions }
|
||||
: field,
|
||||
),
|
||||
[mngInstSelectOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const initialBasicValues: Record<string, any> = {
|
||||
...baseFeatureValues,
|
||||
...normalizeBasicFormValues(change.basicInfo ?? {}),
|
||||
};
|
||||
|
||||
setBasicInfoTableData(initialBasicValues);
|
||||
setInputInfoTableData(buildInitialInputDraft(change));
|
||||
}, [baseFeatureValues, change.changeId, isNewPipe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewPipe) return;
|
||||
|
||||
const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim();
|
||||
const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim();
|
||||
if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd)) return;
|
||||
|
||||
let isMounted = true;
|
||||
const fetchPipeInputInfo = async () => {
|
||||
try {
|
||||
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
|
||||
const safeMngInstCd = encodeURIComponent(mngInstCd);
|
||||
const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`);
|
||||
if (!isMounted || !result) return;
|
||||
|
||||
setInputInfoTableData((previous) => ({
|
||||
...previous,
|
||||
...pickFields(result, PIPE_VISIBLE_INPUT_FIELD_IDS),
|
||||
...pickFields(change.basicInfo ?? {}, PIPE_VISIBLE_INPUT_FIELD_IDS),
|
||||
}));
|
||||
} catch {
|
||||
// Optional pipe input details should not block the edit session.
|
||||
}
|
||||
};
|
||||
|
||||
fetchPipeInputInfo();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]);
|
||||
|
||||
const buildMergedDraft = (
|
||||
nextBasicInfoTableData: Record<string, any>,
|
||||
nextInputInfoTableData: Record<string, any>,
|
||||
): GisEditPipeBasicInfo => {
|
||||
const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS);
|
||||
const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS);
|
||||
if (basicDraft.instlYmd) {
|
||||
basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd);
|
||||
}
|
||||
return {
|
||||
...basicDraft,
|
||||
...inputDraft,
|
||||
};
|
||||
};
|
||||
|
||||
const handleBasicInfoChange = (nextFormData: Record<string, any>) => {
|
||||
setBasicInfoTableData(nextFormData);
|
||||
onSave(change.changeId, buildMergedDraft(nextFormData, inputInfoTableData));
|
||||
};
|
||||
|
||||
const handleInputInfoChange = (nextFormData: Record<string, any>) => {
|
||||
setInputInfoTableData(nextFormData);
|
||||
onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData));
|
||||
};
|
||||
|
||||
const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]);
|
||||
const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]);
|
||||
const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? "");
|
||||
|
||||
return (
|
||||
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
|
||||
<div className="card-header bg-primary flex items-center justify-between relative p-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
|
||||
하수관로
|
||||
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
|
||||
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
|
||||
size="xs"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1">
|
||||
<VerticalTable
|
||||
headName="기본정보"
|
||||
tableData={basicInfoTableRows}
|
||||
headlist={basicInfoFields}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => {}}
|
||||
isEditable={true}
|
||||
onFormDataChange={handleBasicInfoChange}
|
||||
/>
|
||||
<VerticalTable
|
||||
headName="입력정보"
|
||||
tableData={inputInfoTableRows}
|
||||
headlist={PIPE_INPUT_INFO_FIELDS}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => {}}
|
||||
isEditable={true}
|
||||
onFormDataChange={handleInputInfoChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
|
||||
|
||||
type Props = GisEditSessionSnapshot & {
|
||||
onRemoveFocusedChange: () => void;
|
||||
onRemoveLastChange: () => void;
|
||||
onClearChanges: () => void;
|
||||
onFocusChange: (changeId: string) => void;
|
||||
onOpenPopup?: () => void;
|
||||
variant?: "inline" | "popup";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
`block w-full rounded border px-2 py-2 text-left text-[12px] transition ${
|
||||
isFocused
|
||||
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
|
||||
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
|
||||
}`;
|
||||
|
||||
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
|
||||
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
|
||||
|
||||
const getChangeSummary = (change: GisEditSessionSnapshot["changes"][number]) => {
|
||||
if (change.attributeOnly) return "변경한 상세정보를 확인하세요.";
|
||||
if (change.editType === "ADD") return "추가한 객체 위치를 확인하세요.";
|
||||
if (change.editType === "MOVE") return "객체를 드래그해 위치를 조정하세요.";
|
||||
if (change.editType === "VERTEX_EDIT") return "점을 옮기거나 선분을 끌어 추가하세요.";
|
||||
return "삭제할 객체가 맞는지 확인하세요.";
|
||||
};
|
||||
|
||||
type ChangeListProps = {
|
||||
changes: GisEditSessionSnapshot["changes"];
|
||||
focusedChangeId: string | null;
|
||||
onFocusChange: (changeId: string) => void;
|
||||
};
|
||||
|
||||
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
|
||||
<>
|
||||
{changes.map((change, index) => (
|
||||
<button
|
||||
key={change.changeId}
|
||||
type="button"
|
||||
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
|
||||
onClick={() => onFocusChange(change.changeId)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="shrink-0 whitespace-nowrap text-[11px] font-semibold leading-4 text-[#173f67]">
|
||||
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
|
||||
<span className="whitespace-nowrap rounded-full border border-[#a9d9bd] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#16834f]">
|
||||
정보저장
|
||||
</span>
|
||||
) : null}
|
||||
{focusedChangeId === change.changeId ? (
|
||||
<span className="whitespace-nowrap rounded-full border border-[#b8d3ef] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#176db8]">
|
||||
지도 미리보기 선택
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-[#526173]">{getChangeSummary(change)}</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
export default function GisEditSessionPanel({
|
||||
isEditMode,
|
||||
selectedLayerName,
|
||||
activeTool,
|
||||
changes,
|
||||
canUndo,
|
||||
focusedChangeId,
|
||||
onRemoveFocusedChange,
|
||||
onRemoveLastChange,
|
||||
onClearChanges,
|
||||
onFocusChange,
|
||||
onOpenPopup,
|
||||
variant = "inline",
|
||||
className = "",
|
||||
}: Props) {
|
||||
const isCompactInline = variant === "inline" && changes.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${isCompactInline ? "h-[58px] p-2" : "p-4"} ${className}`.trim()}
|
||||
>
|
||||
<div className={`flex shrink-0 justify-between gap-3 ${isCompactInline ? "mb-0 items-center" : "mb-3 items-start"}`}>
|
||||
<div className="min-w-0">
|
||||
{variant === "inline" ? (
|
||||
<div className="mb-1 text-[14px] font-bold text-[#173f67]">편집 세션</div>
|
||||
) : null}
|
||||
<div className="truncate text-[12px] text-[#667485]">
|
||||
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
|
||||
</div>
|
||||
</div>
|
||||
{onOpenPopup ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 rounded border border-[#b8d3ef] bg-white px-2.5 py-1.5 text-[11px] font-semibold text-[#176db8] transition hover:bg-[#eef7ff]"
|
||||
onClick={onOpenPopup}
|
||||
title="편집 세션을 기존 윈도우 팝업으로 열기"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[12px] leading-none">↗</span>
|
||||
<span>팝업</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2`}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
|
||||
disabled={!focusedChangeId}
|
||||
onClick={onRemoveFocusedChange}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
|
||||
disabled={!canUndo}
|
||||
onClick={onRemoveLastChange}
|
||||
>
|
||||
뒤로가기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
|
||||
disabled={!canUndo}
|
||||
onClick={onClearChanges}
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
|
||||
총 {changes.length}건
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}>
|
||||
{changes.length === 0 ? (
|
||||
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
|
||||
저장할 편집 변경분이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
<div className="space-y-2">
|
||||
<ChangeList
|
||||
changes={changes}
|
||||
focusedChangeId={focusedChangeId}
|
||||
onFocusChange={onFocusChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Move,
|
||||
PenTool,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
|
||||
type Props = {
|
||||
isEditMode: boolean;
|
||||
activeTool: GisEditTool | null;
|
||||
isSnapEnabled: boolean;
|
||||
canUseTools: boolean;
|
||||
canSave: boolean;
|
||||
isSaving?: boolean;
|
||||
statusMessage: string;
|
||||
allowedTools?: GisEditTool[];
|
||||
onClose?: () => void;
|
||||
onToggleSnap: () => void;
|
||||
onSelectTool: (tool: GisEditTool) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
const TOOL_BUTTONS: Array<{
|
||||
tool: GisEditTool;
|
||||
label: string;
|
||||
icon: typeof Plus;
|
||||
}> = [
|
||||
{ tool: "ADD", label: "추가", icon: Plus },
|
||||
{ tool: "MOVE", label: "이동", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
|
||||
{ tool: "DELETE", label: "삭제", icon: Trash2 },
|
||||
];
|
||||
|
||||
export default function GisEditToolbar({
|
||||
isEditMode,
|
||||
activeTool,
|
||||
isSnapEnabled,
|
||||
canUseTools,
|
||||
canSave,
|
||||
isSaving = false,
|
||||
statusMessage: _statusMessage,
|
||||
allowedTools = [],
|
||||
onClose,
|
||||
onToggleSnap,
|
||||
onSelectTool,
|
||||
onSave,
|
||||
}: Props) {
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<div data-testid="gis-edit-toolbar" className="absolute bottom-12 left-1/2 z-[60] flex w-[min(960px,calc(100%-2rem))] -translate-x-1/2 flex-col">
|
||||
<div className="card card-border flex !bg-white py-[1px]">
|
||||
<div className="flex w-full items-center gap-2 overflow-hidden">
|
||||
<div className="flex w-[120px] shrink-0 items-center justify-start">
|
||||
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 justify-center">
|
||||
<div className="flex items-center gap-1">
|
||||
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
|
||||
const isActive = activeTool === tool;
|
||||
const isToolEnabled =
|
||||
canUseTools && allowedTools.includes(tool);
|
||||
if (tool === "VERTEX_EDIT") {
|
||||
return (
|
||||
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[98px] px-2"
|
||||
disabled={!isToolEnabled}
|
||||
onClick={() => onSelectTool("VERTEX_EDIT")}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color={isSnapEnabled ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[82px] px-2"
|
||||
disabled={!canUseTools}
|
||||
onClick={onToggleSnap}
|
||||
title="이동과 꼭지점편집에 스냅핑 적용"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>스냅핑</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={tool}
|
||||
type="button"
|
||||
color={isActive ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
|
||||
disabled={!isToolEnabled}
|
||||
onClick={() => onSelectTool(tool)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[132px] shrink-0 items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mt-1 min-w-[58px] shrink-0 px-2"
|
||||
disabled={!canSave || isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isSaving ? "저장 중" : "저장"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="sm"
|
||||
className="mt-1 min-w-[58px] shrink-0 px-2"
|
||||
onClick={onClose}
|
||||
title="하수관로 편집 닫기"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<X className="h-4 w-4" />
|
||||
<span>닫기</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
|
||||
|
||||
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
|
||||
|
||||
export type GisEditPreviewColors = {
|
||||
pointColor: string;
|
||||
lineColor: string;
|
||||
fillColor: string;
|
||||
};
|
||||
|
||||
export type GisEditFeatureRef = {
|
||||
fcltMngNo?: string;
|
||||
mngInstCd?: string;
|
||||
fid?: string;
|
||||
};
|
||||
|
||||
export type GisEditPipeBasicInfo = {
|
||||
[key: string]: string | null | undefined;
|
||||
fcltMngNo?: string | null;
|
||||
mngInstCd?: string | null;
|
||||
instMngNo?: string | null;
|
||||
instlYmd?: string | null;
|
||||
mapshtNo?: string | null;
|
||||
pdmtVl?: string | null;
|
||||
prlgVl?: string | null;
|
||||
usgCd?: string | null;
|
||||
mqltCd?: string | null;
|
||||
avgGrdn?: string | null;
|
||||
drtwMltvArea?: string | null;
|
||||
exclnMltvArea?: string | null;
|
||||
tmptDpth?: string | null;
|
||||
tmptMhlMngNo?: string | null;
|
||||
scendDpth?: string | null;
|
||||
scendMhlMngNo?: string | null;
|
||||
trflnCnt?: string | null;
|
||||
vrtcLen?: string | null;
|
||||
wdthLen?: string | null;
|
||||
instlMbdyCd?: string | null;
|
||||
swtrmtZoneNm?: string | null;
|
||||
mltvZoneNm?: string | null;
|
||||
mltvDtdvNm?: string | null;
|
||||
lineCd?: string | null;
|
||||
floCd?: string | null;
|
||||
layCd?: string | null;
|
||||
};
|
||||
|
||||
export type GisEditLayerInfo = {
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
geometryType: GisEditGeometryType;
|
||||
editableYn: "Y" | "N";
|
||||
allowedTools: GisEditTool[];
|
||||
previewColors: GisEditPreviewColors;
|
||||
disabledReason?: string;
|
||||
};
|
||||
|
||||
export type GisEditChange = {
|
||||
changeId: string;
|
||||
editType: GisEditTool;
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
objectId?: string;
|
||||
fid?: string;
|
||||
baseEditSn?: string;
|
||||
featureRef?: GisEditFeatureRef;
|
||||
objectLabel: string;
|
||||
beforeGeometry?: Record<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
featureProperties?: Record<string, unknown> | null;
|
||||
basicInfo?: GisEditPipeBasicInfo | null;
|
||||
basicInfoSavedAt?: string | null;
|
||||
attributeOnly?: boolean;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
|
||||
};
|
||||
|
||||
export type GisEditSavePayload = {
|
||||
screenId: string;
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
saveTargetType: "VERSION" | "FINAL";
|
||||
view: {
|
||||
srs: string;
|
||||
bbox: string | null;
|
||||
};
|
||||
changes: Array<{
|
||||
changeId: string;
|
||||
editType: GisEditTool;
|
||||
objectId?: string;
|
||||
clientTempId?: string;
|
||||
fid?: string;
|
||||
baseEditSn?: string;
|
||||
featureRef?: GisEditFeatureRef;
|
||||
attributes?: GisEditPipeBasicInfo | null;
|
||||
beforeGeometry?: Record<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
geometry?: Record<string, unknown> | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GisEditSessionSnapshot = {
|
||||
isEditMode: boolean;
|
||||
selectedLayerName: string;
|
||||
activeTool: GisEditTool | null;
|
||||
isSnapEnabled: boolean;
|
||||
changes: GisEditChange[];
|
||||
lastSavedPayload: GisEditSavePayload | null;
|
||||
canUndo: boolean;
|
||||
focusedChangeId: string | null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
|
||||
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
type UploadFacilityType = {
|
||||
facilityTypeCode: string;
|
||||
facilityTypeName: string;
|
||||
allowedExtensions: string[];
|
||||
maxFileSizeMb: number;
|
||||
};
|
||||
|
||||
type UploadHistoryItem = {
|
||||
uploadId: string;
|
||||
facilityTypeCode: string;
|
||||
facilityTypeName: string;
|
||||
fileName: string;
|
||||
registeredAt: string;
|
||||
registeredBy: string;
|
||||
};
|
||||
|
||||
const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [
|
||||
{
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"],
|
||||
maxFileSizeMb: 200,
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260504-0001",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260504.zip",
|
||||
registeredAt: "2026-05-04",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260418-0004",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260418.zip",
|
||||
registeredAt: "2026-04-18",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260409-0007",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260409.zip",
|
||||
registeredAt: "2026-04-09",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260331-0010",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260331.zip",
|
||||
registeredAt: "2026-03-31",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260322-0013",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260322.zip",
|
||||
registeredAt: "2026-03-22",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260313-0016",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260313.zip",
|
||||
registeredAt: "2026-03-13",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260304-0019",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260304.zip",
|
||||
registeredAt: "2026-03-04",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
];
|
||||
|
||||
const getFileExtension = (fileName: string) => {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
if (lastDotIndex < 0) return "";
|
||||
return fileName.slice(lastDotIndex).toLowerCase();
|
||||
};
|
||||
|
||||
const buildTodayDate = () => new Date().toISOString().slice(0, 10);
|
||||
const GIS_UPLOAD_PAGE_SIZE = 10;
|
||||
|
||||
export default function GisManagementPopupPage() {
|
||||
// 브라우저 파일 picker는 제어형 input보다 ref 제어가 안전하다.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>(
|
||||
INITIAL_UPLOAD_HISTORY,
|
||||
);
|
||||
const [selectedUploadId, setSelectedUploadId] = useState<string>(
|
||||
INITIAL_UPLOAD_HISTORY[0]?.uploadId ?? "",
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const selectedFacility = useMemo(
|
||||
() =>
|
||||
UPLOAD_FACILITY_TYPES.find(
|
||||
(facilityType) =>
|
||||
facilityType.facilityTypeCode === selectedFacilityTypeCode,
|
||||
) ?? null,
|
||||
[selectedFacilityTypeCode],
|
||||
);
|
||||
|
||||
const selectedHistory = useMemo(
|
||||
() =>
|
||||
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
|
||||
null,
|
||||
[selectedUploadId, uploadHistory],
|
||||
);
|
||||
|
||||
const pagedUploadHistory = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE;
|
||||
return uploadHistory.slice(startIndex, startIndex + GIS_UPLOAD_PAGE_SIZE);
|
||||
}, [currentPage, uploadHistory]);
|
||||
|
||||
const resetSelectedFile = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleFacilityTypeChange = (
|
||||
event: ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
setSelectedFacilityTypeCode(event.target.value);
|
||||
resetSelectedFile();
|
||||
};
|
||||
|
||||
const handleOpenFilePicker = () => {
|
||||
if (!selectedFacility) {
|
||||
window.alert("시설구분을 먼저 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
|
||||
if (!selectedFacility) {
|
||||
resetSelectedFile();
|
||||
window.alert("시설구분을 먼저 선택한 뒤 파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextFile) {
|
||||
resetSelectedFile();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExtension = getFileExtension(nextFile.name);
|
||||
const isAllowedExtension = selectedFacility.allowedExtensions.includes(
|
||||
fileExtension,
|
||||
);
|
||||
|
||||
if (!isAllowedExtension) {
|
||||
resetSelectedFile();
|
||||
window.alert(
|
||||
`업로드 가능한 GIS 파일 형식이 아닙니다. 허용 형식: ${selectedFacility.allowedExtensions.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxFileSizeInBytes = selectedFacility.maxFileSizeMb * 1024 * 1024;
|
||||
if (nextFile.size > maxFileSizeInBytes) {
|
||||
resetSelectedFile();
|
||||
window.alert(
|
||||
`파일 크기가 제한을 초과했습니다. 최대 ${selectedFacility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(nextFile);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!selectedFacility) {
|
||||
window.alert("시설구분을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
window.alert("업로드할 파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicateFile = uploadHistory.some(
|
||||
(history) =>
|
||||
history.facilityTypeCode === selectedFacility.facilityTypeCode &&
|
||||
history.fileName.toLowerCase() === selectedFile.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if (isDuplicateFile) {
|
||||
window.alert("동일 시설구분에 같은 파일명이 존재합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUploadHistory: UploadHistoryItem = {
|
||||
uploadId: `GUP-MOCK-${Date.now()}`,
|
||||
facilityTypeCode: selectedFacility.facilityTypeCode,
|
||||
facilityTypeName: selectedFacility.facilityTypeName,
|
||||
fileName: selectedFile.name,
|
||||
registeredAt: buildTodayDate(),
|
||||
registeredBy: "admin",
|
||||
};
|
||||
|
||||
setUploadHistory((currentHistory) => [
|
||||
nextUploadHistory,
|
||||
...currentHistory,
|
||||
]);
|
||||
setCurrentPage(1);
|
||||
setSelectedUploadId(nextUploadHistory.uploadId);
|
||||
window.alert("GIS 업로드가 완료되었습니다.");
|
||||
resetSelectedFile();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!selectedHistory) {
|
||||
window.alert("다운로드할 파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(`${selectedHistory.fileName} 다운로드를 요청했습니다.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-modal-page">
|
||||
<section className="sn-popup-modal-shell max-w-[920px] shadow-[0_16px_36px_rgba(31,59,87,0.10)] backdrop-blur-[2px]">
|
||||
<header className="sn-popup-modal-header">
|
||||
<h1 className="sn-popup-modal-title">
|
||||
GIS 관리
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="px-[16px] pb-[16px] pt-[10px]">
|
||||
<div className="sn-popup-panel-title mb-[10px] mt-[4px]">
|
||||
GIS 업로드
|
||||
</div>
|
||||
|
||||
<div className="mb-[8px] flex items-center justify-between gap-[12px] px-[2px]">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-[10px]">
|
||||
<select
|
||||
className="sn-popup-input-sm h-[32px] w-[110px] shrink-0"
|
||||
value={selectedFacilityTypeCode}
|
||||
onChange={handleFacilityTypeChange}
|
||||
aria-label="시설구분"
|
||||
>
|
||||
<option value="">시설구분</option>
|
||||
{UPLOAD_FACILITY_TYPES.map((facilityType) => (
|
||||
<option
|
||||
key={facilityType.facilityTypeCode}
|
||||
value={facilityType.facilityTypeCode}
|
||||
>
|
||||
{facilityType.facilityTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Input
|
||||
value={selectedFile?.name ?? ""}
|
||||
placeholder="선택된 파일 없음"
|
||||
readOnly
|
||||
aria-label="선택 파일명"
|
||||
className="sn-popup-input-sm h-[32px] min-w-0 flex-1 py-0 !text-[1.02rem] shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-[10px]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="sn-popup-primary-button-sm h-[32px] min-w-[56px]"
|
||||
onClick={handleOpenFilePicker}
|
||||
>
|
||||
찾기
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="sn-popup-primary-button-sm h-[32px] min-w-[64px]"
|
||||
onClick={handleUpload}
|
||||
>
|
||||
업로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={
|
||||
selectedFacility?.allowedExtensions.join(",") ??
|
||||
".zip,.shp,.geojson,.json,.dxf"
|
||||
}
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
|
||||
<div className="mb-[8px] flex items-center justify-between px-[2px]">
|
||||
<div className="sn-popup-panel-title">
|
||||
GIS 업로드 현황
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button-sm h-[32px] min-w-[82px] px-[10px]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="sn-popup-card shadow-[0_6px_16px_rgba(31,59,87,0.06),inset_0_1px_0_rgba(255,255,255,0.78)]">
|
||||
<Table variant="secondary" className="text-[1.02rem]">
|
||||
<TableHeader className="!static bg-transparent">
|
||||
<TableRow className="border-0">
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
no
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
시설구분
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
파일명
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
등록일자
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
등록자
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{pagedUploadHistory.map((history, index) => {
|
||||
const isSelected = history.uploadId === selectedUploadId;
|
||||
const backgroundColor = isSelected
|
||||
? "var(--primary-transparent)"
|
||||
: index % 2 === 0
|
||||
? "var(--background)"
|
||||
: "var(--secondary)";
|
||||
const rowNumber =
|
||||
(currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={history.uploadId}
|
||||
className="border-0 cursor-pointer"
|
||||
onClick={() => setSelectedUploadId(history.uploadId)}
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{rowNumber}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.facilityTypeName}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.fileName}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.registeredAt}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.registeredBy}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? (
|
||||
<div className="mt-[10px] flex justify-center">
|
||||
<UiPageNations
|
||||
total={uploadHistory.length}
|
||||
cur={currentPage}
|
||||
pageSize={GIS_UPLOAD_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
import {
|
||||
INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
|
||||
type InftwtResultRow,
|
||||
} from "./inftwtResultTypes";
|
||||
|
||||
const INFTWT_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const INFTWT_RESULT_HEADLIST = [
|
||||
{ id: "rowNumber", title: "일련번호" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "적용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
|
||||
type InftwtResultFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
sggName: string;
|
||||
swtrmtFcltNm: string;
|
||||
aplcnYmFrom: string;
|
||||
aplcnYmTo: string;
|
||||
};
|
||||
|
||||
const createDefaultAplcnYm = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: InftwtResultFilters = {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
sggName: "",
|
||||
swtrmtFcltNm: "",
|
||||
aplcnYmFrom: `${new Date().getFullYear()}01`,
|
||||
aplcnYmTo: createDefaultAplcnYm(),
|
||||
};
|
||||
|
||||
const postRowSelectMessage = (row: InftwtResultRow) => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
row,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("침입수 분석결과 선택 동기화 저장에 실패했습니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || !window.opener) return;
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
export default function InftwtResultPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
|
||||
const [selectedRow, setSelectedRow] = useState<InftwtResultRow | null>(null);
|
||||
const [rows, setRows] = useState<InftwtResultRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: InftwtResultFilters) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(INFTWT_RESULT_PAGE_SIZE),
|
||||
sort: "inftwtAnlsSn,desc",
|
||||
});
|
||||
|
||||
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
|
||||
|
||||
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
const nextRows = content
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
|
||||
const sggOption = sgg.find((code) => code.val === item?.sggCd);
|
||||
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
|
||||
scsYn: String(item?.scsYn ?? "N"),
|
||||
} as InftwtResultRow;
|
||||
})
|
||||
.filter((item: InftwtResultRow) => item.scsYn === "Y");
|
||||
|
||||
setRows(nextRows);
|
||||
setTotal(Number(response?.totalElements ?? nextRows.length));
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 목록 조회 중 오류:", error);
|
||||
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, DEFAULT_FILTERS);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (page = 1) => {
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedRow(row);
|
||||
postRowSelectMessage(row);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedRow?.inftwtAnlsSn) {
|
||||
alert("다운로드할 분석결과를 먼저 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
|
||||
const excelRows = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: {
|
||||
inftwtAnlsSn: "침입수분석\n일련번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 다운로드 중 오류:", error);
|
||||
alert("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">침입수 분석결과</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="rounded-md border border-border bg-secondary p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmFrom,
|
||||
onChange: (value: string) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
aplcnYmFrom: value,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
startYear={1900}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmTo,
|
||||
onChange: (value: string) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
aplcnYmTo: value,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
startYear={1900}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={filters.ctpvCd}
|
||||
sigungu={filters.sggCd}
|
||||
sggName={filters.sggName}
|
||||
onSidoChange={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
ctpvCd: value,
|
||||
sggCd: "0",
|
||||
sggName: "",
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
onSigunguChange={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggCd: value,
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
setSggName={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggName: value ?? "",
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
|
||||
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
|
||||
setSwtrmtFcltNm={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
swtrmtFcltNm: value ?? "",
|
||||
}))
|
||||
}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
size="sm"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<UiTable<InftwtResultRow>
|
||||
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
|
||||
tableData={rows}
|
||||
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
cellClassName={(_, headId) =>
|
||||
headId === "anlsInfo"
|
||||
? "whitespace-normal break-all leading-[1.35]"
|
||||
: ""
|
||||
}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRow ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
{"선택 분석: "}
|
||||
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
|
||||
export const INFTWT_RESULT_OVERLAY_TYPE = "INFTWT_RESULT";
|
||||
export const INFTWT_RESULT_ROW_SELECT_STORAGE_KEY = "SN_INFTWT_RESULT_ROW_SELECT_STORAGE";
|
||||
|
||||
export type InftwtResultRow = {
|
||||
rowNumber: number;
|
||||
inftwtAnlsSn: number;
|
||||
ctpvCd?: string;
|
||||
ctpvNm: string;
|
||||
sggCd?: string;
|
||||
sggNm: string;
|
||||
swtrmtFcltNm: string;
|
||||
aplcnYm: string;
|
||||
drtwSwtcRt?: number | string;
|
||||
anlsInfo: string;
|
||||
regDt?: string;
|
||||
rgtrNm?: string;
|
||||
scsYn?: string;
|
||||
};
|
||||
|
||||
export type InftwtResultMapViewPipe = {
|
||||
pplMngNo: string;
|
||||
fcltMngNo: string;
|
||||
hasOilq: boolean;
|
||||
};
|
||||
|
||||
export type InftwtResultMapViewResponse = {
|
||||
analysis: {
|
||||
inftwtAnlsSn: number;
|
||||
aplcnYm: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
swtrmtFcltNm?: string;
|
||||
anlsInfo?: string;
|
||||
};
|
||||
focusTargetType: "ZONE" | "PIPE_EXTENT";
|
||||
focusTargetName: string;
|
||||
focusLayerType?: "tb_ms_swage_prcs_zone" | "tb_ms_swage_ppl";
|
||||
focusFid?: string;
|
||||
focusFallbackApplied?: boolean;
|
||||
zoneGeometry: Record<string, unknown>;
|
||||
pipeGeometry: Record<string, unknown>;
|
||||
pipes: InftwtResultMapViewPipe[];
|
||||
pipeCount: number;
|
||||
oilqPipeCount: number;
|
||||
};
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
import VectorLayer from "ol/layer/Vector.js";
|
||||
import VectorSource from "ol/source/Vector.js";
|
||||
import Stroke from "ol/style/Stroke.js";
|
||||
import Fill from "ol/style/Fill.js";
|
||||
import Style from "ol/style/Style.js";
|
||||
import { intersects as intersectsExtent } from "ol/extent";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
|
||||
|
||||
import {
|
||||
INFTWT_RESULT_OVERLAY_TYPE,
|
||||
INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
|
||||
type InftwtResultMapViewResponse,
|
||||
type InftwtResultRow,
|
||||
} from "./inftwtResultTypes";
|
||||
|
||||
type UseInftwtResultMapParams = {
|
||||
gisMaps: MutableRefObject<any | null>;
|
||||
setFeatureData: (value: any) => void;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
setIsSearchModalOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER";
|
||||
const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER";
|
||||
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
|
||||
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
|
||||
const zoneStyle = new Style({
|
||||
fill: new Fill({
|
||||
color: "rgba(16, 185, 129, 0.06)",
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: "rgba(5, 150, 105, 0.72)",
|
||||
width: 2,
|
||||
lineDash: [10, 8],
|
||||
}),
|
||||
zIndex: 19991,
|
||||
});
|
||||
|
||||
const buildPipeStyle = (hasOilq: boolean) => {
|
||||
const innerColor = hasOilq ? "rgba(236, 72, 153, 0.98)" : "rgba(14, 165, 233, 0.98)";
|
||||
const haloWidth = hasOilq ? 11 : 5;
|
||||
const innerWidth = hasOilq ? 6 : 2.5;
|
||||
const dashPattern = hasOilq ? [18, 9] : undefined;
|
||||
|
||||
return [
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(15, 23, 42, 0.58)",
|
||||
width: hasOilq ? haloWidth + 4 : haloWidth + 2,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
}),
|
||||
zIndex: hasOilq ? 20000 : 19999,
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(255, 255, 255, 0.98)",
|
||||
width: haloWidth,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
}),
|
||||
zIndex: hasOilq ? 20001 : 20000,
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: innerColor,
|
||||
width: innerWidth,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
lineDash: dashPattern,
|
||||
}),
|
||||
zIndex: hasOilq ? 20002 : 20001,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getFeatureCollection = (payload: any) => {
|
||||
if (payload?.type === "FeatureCollection") return payload;
|
||||
if (payload?.data?.type === "FeatureCollection") return payload.data;
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
};
|
||||
|
||||
const normalizeProjectionName = (value: string) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "EPSG:4326";
|
||||
if (trimmed.startsWith("EPSG:")) return trimmed;
|
||||
const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/);
|
||||
return match?.[1] ? `EPSG:${match[1]}` : trimmed;
|
||||
};
|
||||
|
||||
const resolveFeatureCollectionProjection = (payload: any) => {
|
||||
const featureCollection = getFeatureCollection(payload);
|
||||
const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim();
|
||||
return normalizeProjectionName(crsName || GisApp.TO_PROJ);
|
||||
};
|
||||
|
||||
const getOverlayFlag = (data: any) =>
|
||||
String(
|
||||
data?.get?.("analysisOverlayType") ??
|
||||
data?.values_?.analysisOverlayType ??
|
||||
data?.analysisOverlayType ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayBasePplMngNo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("fcltMngNo") ??
|
||||
data?.values_?.fcltMngNo ??
|
||||
data?.values_?.fclt_mng_no ??
|
||||
data?.fcltMngNo ??
|
||||
data?.fclt_mng_no ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisPplMngNo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("pplMngNo") ??
|
||||
data?.values_?.pplMngNo ??
|
||||
data?.pplMngNo ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisSn = (data: any) =>
|
||||
Number(
|
||||
data?.get?.("inftwtAnlsSn") ??
|
||||
data?.values_?.inftwtAnlsSn ??
|
||||
data?.inftwtAnlsSn ??
|
||||
0,
|
||||
);
|
||||
|
||||
const getOverlayAplcnYm = (data: any) =>
|
||||
String(
|
||||
data?.get?.("aplcnYm") ??
|
||||
data?.values_?.aplcnYm ??
|
||||
data?.aplcnYm ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisInfo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("analysisInfo") ??
|
||||
data?.values_?.analysisInfo ??
|
||||
data?.analysisInfo ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getFirstFeatureId = (payload: any) => {
|
||||
const featureCollection = getFeatureCollection(payload);
|
||||
const firstFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : null;
|
||||
return String(firstFeature?.id ?? "").trim();
|
||||
};
|
||||
|
||||
const getOilqPipeExtent = (features: any[]) => {
|
||||
const oilqFeatures = features.filter((feature) => Boolean(feature?.get?.("hasOilq")));
|
||||
if (oilqFeatures.length === 0) return null;
|
||||
|
||||
let nextExtent: [number, number, number, number] | null = null;
|
||||
oilqFeatures.forEach((feature) => {
|
||||
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
|
||||
if (!Array.isArray(geometryExtent) || geometryExtent.some((value: number) => !Number.isFinite(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextExtent) {
|
||||
nextExtent = [...geometryExtent] as [number, number, number, number];
|
||||
return;
|
||||
}
|
||||
|
||||
nextExtent = [
|
||||
Math.min(nextExtent[0], geometryExtent[0]),
|
||||
Math.min(nextExtent[1], geometryExtent[1]),
|
||||
Math.max(nextExtent[2], geometryExtent[2]),
|
||||
Math.max(nextExtent[3], geometryExtent[3]),
|
||||
];
|
||||
});
|
||||
|
||||
return nextExtent;
|
||||
};
|
||||
|
||||
export const useInftwtResultMap = ({
|
||||
gisMaps,
|
||||
setFeatureData,
|
||||
setIsOpen,
|
||||
setIsSearchModalOpen,
|
||||
}: UseInftwtResultMapParams) => {
|
||||
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
|
||||
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const zoneSourceRef = useRef<VectorSource | null>(null);
|
||||
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const pipeSourceRef = useRef<VectorSource | null>(null);
|
||||
const mapViewCacheRef = useRef<Map<number, InftwtResultMapViewResponse>>(new Map());
|
||||
const [activeMapView, setActiveMapView] = useState<InftwtResultMapViewResponse | null>(null);
|
||||
|
||||
const clearLayers = useCallback(() => {
|
||||
zoneSourceRef.current?.clear?.();
|
||||
pipeSourceRef.current?.clear?.();
|
||||
zoneLayerRef.current?.changed?.();
|
||||
pipeLayerRef.current?.changed?.();
|
||||
gisMaps.current?.renderSync?.();
|
||||
}, [gisMaps]);
|
||||
|
||||
const ensureZoneLayer = useCallback(() => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) return null;
|
||||
|
||||
if (zoneLayerRef.current && zoneSourceRef.current) {
|
||||
return zoneSourceRef.current;
|
||||
}
|
||||
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 19991,
|
||||
});
|
||||
|
||||
layer.set("id", INFTWT_ZONE_LAYER_ID);
|
||||
layer.set("lyrNm", "移⑥엯?섎텇??泥섎━援ъ뿭");
|
||||
layer.set("lyrTyNm", "tb_ms_swage_prcs_zone");
|
||||
layer.set("clickPsblYn", "N");
|
||||
layer.setVisible?.(true);
|
||||
map.addLayer(layer);
|
||||
|
||||
zoneLayerRef.current = layer;
|
||||
zoneSourceRef.current = source;
|
||||
return source;
|
||||
}, [gisMaps]);
|
||||
|
||||
const ensurePipeLayer = useCallback(() => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) return null;
|
||||
|
||||
if (pipeLayerRef.current && pipeSourceRef.current) {
|
||||
return pipeSourceRef.current;
|
||||
}
|
||||
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 19997,
|
||||
});
|
||||
|
||||
layer.set("id", INFTWT_PIPE_LAYER_ID);
|
||||
layer.set("lyrNm", "침입수분석 결과관로");
|
||||
layer.set("lyrTyNm", "tb_ms_swage_ppl");
|
||||
layer.set("clickPsblYn", "Y");
|
||||
layer.setVisible?.(true);
|
||||
map.addLayer(layer);
|
||||
|
||||
pipeLayerRef.current = layer;
|
||||
pipeSourceRef.current = source;
|
||||
return source;
|
||||
}, [gisMaps]);
|
||||
|
||||
const fitToRenderedFeatures = useCallback(
|
||||
(mapView?: InftwtResultMapViewResponse | null) => {
|
||||
const map = gisMaps.current;
|
||||
const zoneExtent = zoneSourceRef.current?.getExtent?.();
|
||||
const pipeExtent = pipeSourceRef.current?.getExtent?.();
|
||||
const pipeFeatures = pipeSourceRef.current?.getFeatures?.() ?? [];
|
||||
const oilqPipeExtent = getOilqPipeExtent(pipeFeatures);
|
||||
const hasPipeFeatures = pipeFeatures.length > 0;
|
||||
const hasZoneFeatures = Boolean(zoneSourceRef.current?.getFeatures?.().length);
|
||||
const preferZone = mapView?.focusTargetType === "ZONE" && hasZoneFeatures;
|
||||
const zoneExtentIsFinite = Array.isArray(zoneExtent) && zoneExtent.every((value: number) => Number.isFinite(value));
|
||||
const pipeExtentIsFinite = Array.isArray(pipeExtent) && pipeExtent.every((value: number) => Number.isFinite(value));
|
||||
const hasPipeInsideZone =
|
||||
preferZone && zoneExtentIsFinite
|
||||
? pipeFeatures.some((feature: any) => {
|
||||
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
|
||||
return Array.isArray(geometryExtent)
|
||||
&& geometryExtent.every((value: number) => Number.isFinite(value))
|
||||
&& intersectsExtent(zoneExtent as [number, number, number, number], geometryExtent as [number, number, number, number]);
|
||||
})
|
||||
: false;
|
||||
|
||||
const shouldUseZoneExtent = preferZone && (hasPipeInsideZone || !hasPipeFeatures);
|
||||
const extent = oilqPipeExtent
|
||||
? oilqPipeExtent
|
||||
: shouldUseZoneExtent
|
||||
? zoneExtent
|
||||
: hasPipeFeatures
|
||||
? pipeExtent
|
||||
: hasZoneFeatures
|
||||
? zoneExtent
|
||||
: null;
|
||||
|
||||
if (!map || !extent || extent.some((value: number) => !Number.isFinite(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.getView?.().fit?.(extent, {
|
||||
duration: 350,
|
||||
padding: DEFAULT_RESULT_FIT_PADDING,
|
||||
maxZoom: oilqPipeExtent ? 17 : shouldUseZoneExtent ? 15 : 16,
|
||||
constrainResolution: true,
|
||||
nearest: true,
|
||||
});
|
||||
},
|
||||
[gisMaps],
|
||||
);
|
||||
|
||||
const moveByRegionCodes = useCallback(
|
||||
(ctpvCd?: string, sggCd?: string) => {
|
||||
const normalizedSggCd = String(sggCd ?? "").trim();
|
||||
if (normalizedSggCd && normalizedSggCd !== "0") {
|
||||
const sigunguCenter = getSigunguCenter(normalizedSggCd);
|
||||
if (sigunguCenter) {
|
||||
moveMapToCenter(gisMaps, sigunguCenter, 12);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCtpvCd = String(ctpvCd ?? "").trim();
|
||||
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
|
||||
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
|
||||
if (sidoCenterCoordinate) {
|
||||
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[gisMaps],
|
||||
);
|
||||
|
||||
const moveToStableFocus = useCallback(
|
||||
async (mapView: InftwtResultMapViewResponse) => {
|
||||
if ((mapView.oilqPipeCount ?? 0) > 0) {
|
||||
fitToRenderedFeatures(mapView);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRow = activeResultRowRef.current;
|
||||
const movedByRegion = moveByRegionCodes(
|
||||
activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd,
|
||||
activeRow?.sggCd ?? mapView.analysis?.sggCd,
|
||||
);
|
||||
if (movedByRegion) return;
|
||||
|
||||
fitToRenderedFeatures(mapView);
|
||||
},
|
||||
[fitToRenderedFeatures, moveByRegionCodes],
|
||||
);
|
||||
|
||||
const renderMapView = useCallback(
|
||||
async (mapView: InftwtResultMapViewResponse) => {
|
||||
const zoneSource = ensureZoneLayer();
|
||||
const pipeSource = ensurePipeLayer();
|
||||
if (!zoneSource || !pipeSource) return;
|
||||
|
||||
clearLayers();
|
||||
|
||||
const zoneDataProjection = resolveFeatureCollectionProjection(mapView.zoneGeometry);
|
||||
const zoneFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.zoneGeometry), {
|
||||
dataProjection: zoneDataProjection,
|
||||
featureProjection: GisApp.TO_PROJ,
|
||||
});
|
||||
zoneFeatures.forEach((feature: any) => feature.setStyle(zoneStyle));
|
||||
zoneSource.addFeatures(zoneFeatures);
|
||||
zoneSource.changed?.();
|
||||
zoneLayerRef.current?.setVisible?.(true);
|
||||
zoneLayerRef.current?.changed?.();
|
||||
|
||||
const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry);
|
||||
const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), {
|
||||
dataProjection: pipeDataProjection,
|
||||
featureProjection: GisApp.TO_PROJ,
|
||||
});
|
||||
pipeFeatures.forEach((feature: any) => {
|
||||
const hasOilq = Boolean(feature.get?.("hasOilq"));
|
||||
feature.setStyle(buildPipeStyle(hasOilq));
|
||||
});
|
||||
pipeSource.addFeatures(pipeFeatures);
|
||||
pipeSource.changed?.();
|
||||
pipeLayerRef.current?.setVisible?.(true);
|
||||
pipeLayerRef.current?.changed?.();
|
||||
gisMaps.current?.renderSync?.();
|
||||
|
||||
console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", {
|
||||
focusTargetType: mapView.focusTargetType,
|
||||
zoneFeatureCount: zoneFeatures.length,
|
||||
pipeFeatureCount: pipeFeatures.length,
|
||||
});
|
||||
|
||||
await moveToStableFocus(mapView);
|
||||
},
|
||||
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
|
||||
);
|
||||
|
||||
const handleRowSelect = useCallback(
|
||||
async (row: InftwtResultRow) => {
|
||||
try {
|
||||
activeResultRowRef.current = row;
|
||||
setFeatureData(null);
|
||||
setIsOpen(false);
|
||||
setIsSearchModalOpen(false);
|
||||
|
||||
const cachedMapView = mapViewCacheRef.current.get(row.inftwtAnlsSn);
|
||||
let mapView = cachedMapView;
|
||||
|
||||
if (!mapView) {
|
||||
const payload = await getRawJson(`/sewerage-service/api/v1/inftwtAnlsRslt/mapView/${row.inftwtAnlsSn}`);
|
||||
mapView = (payload?.data ?? payload) as InftwtResultMapViewResponse;
|
||||
mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(INFTWT_PROC_SEARCH_EVENT, {
|
||||
detail: {
|
||||
ctpvCd: row.ctpvCd ?? "0",
|
||||
sggCd: row.sggCd ?? "0",
|
||||
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
|
||||
suppressMapMove: (mapView.oilqPipeCount ?? 0) > 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setActiveMapView(mapView);
|
||||
void renderMapView(mapView);
|
||||
} catch (error) {
|
||||
console.error("移⑥엯??遺꾩꽍寃곌낵 吏???곌퀎 以??ㅻ쪟:", error);
|
||||
alert("移⑥엯??遺꾩꽍寃곌낵瑜?吏?꾩뿉 ?곌퀎?섏? 紐삵뻽?듬땲??");
|
||||
}
|
||||
},
|
||||
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
|
||||
void handleRowSelect(event.data.row as InftwtResultRow);
|
||||
};
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow };
|
||||
if (!payload?.row) return;
|
||||
void handleRowSelect(payload.row);
|
||||
} catch (error) {
|
||||
console.warn("移⑥엯??遺꾩꽍寃곌낵 storage ?숆린??媛믪쓣 ?댁꽍?섏? 紐삵뻽?듬땲??", error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handlePopupMessage);
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
return () => {
|
||||
window.removeEventListener("message", handlePopupMessage);
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
};
|
||||
}, [handleRowSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearLayers();
|
||||
};
|
||||
}, [clearLayers]);
|
||||
|
||||
const handleOverlayFeatureSelect = useCallback((data: any) => {
|
||||
if (getOverlayFlag(data) !== INFTWT_RESULT_OVERLAY_TYPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlayAnalysisSn = getOverlayAnalysisSn(data);
|
||||
const basePplMngNo = getOverlayBasePplMngNo(data);
|
||||
const analysisPplMngNo = getOverlayAnalysisPplMngNo(data);
|
||||
const currentRow = activeResultRowRef.current;
|
||||
const popupWindow = openWindowPopup("infilChart", {
|
||||
pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""),
|
||||
inftwtAnlsSn: overlayAnalysisSn || currentRow?.inftwtAnlsSn || 0,
|
||||
aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "",
|
||||
analysisInfo:
|
||||
getOverlayAnalysisInfo(data) ||
|
||||
currentRow?.anlsInfo ||
|
||||
activeMapView?.analysis?.anlsInfo ||
|
||||
"",
|
||||
resultOnly: "Y",
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert("?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeMapView]);
|
||||
|
||||
return {
|
||||
activeMapView,
|
||||
clearLayers,
|
||||
handleOverlayFeatureSelect,
|
||||
};
|
||||
};
|
||||
+18
-15
@@ -42,6 +42,9 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
|
||||
"materialName",
|
||||
] as const;
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
|
||||
export default function PipeAnalysisDetailPopupPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
@@ -118,7 +121,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error("관로현황 조회 중 오류:", error);
|
||||
alert("관로현황 조회 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
@@ -188,7 +191,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("관로현황 다운로드 중 오류:", error);
|
||||
alert("관로현황 다운로드 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -244,18 +247,18 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]);
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<main className="sn-popup-page">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">관로현황{titleSuffix}</h1>
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
|
||||
</header>
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">용도</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">용도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.useCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -273,9 +276,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관재질</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관재질</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.materialCode}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -293,9 +296,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 시작</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 시작</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.diameterFrom}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -310,9 +313,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 종료</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.diameterTo}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
@@ -32,6 +33,7 @@ const SUMMARY_FILE_PREFIX: Record<string, string> = {
|
||||
MATERIAL: "관로분석_관재질별",
|
||||
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
|
||||
};
|
||||
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
|
||||
|
||||
const toExcelColumnName = (index: number) => {
|
||||
let current = index;
|
||||
@@ -57,6 +59,9 @@ const formatGridNumber = (value: unknown) => {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
|
||||
export default function PipeAnalysisPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
@@ -70,6 +75,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
|
||||
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
|
||||
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
@@ -138,10 +144,11 @@ export default function PipeAnalysisPopupPage() {
|
||||
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
|
||||
setDraftFilters(scopedFilters);
|
||||
setAppliedFilters(scopedFilters);
|
||||
setSummaryCurrentPage(1);
|
||||
await fetchSummary(scopedFilters);
|
||||
} catch (error) {
|
||||
console.error("관로분석 집계 조회 중 오류:", error);
|
||||
alert("관로분석 집계 조회 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
|
||||
setRows([]);
|
||||
setMaterialColumns([]);
|
||||
} finally {
|
||||
@@ -216,7 +223,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
return exportRow;
|
||||
});
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: exportRows as JSON[],
|
||||
data: exportRows as unknown as JSON[],
|
||||
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
|
||||
sheetNameArray: ["관로분석 현황"],
|
||||
headerRange,
|
||||
@@ -225,7 +232,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("관로분석 집계 다운로드 중 오류:", error);
|
||||
alert("관로분석 집계 다운로드 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -280,6 +287,25 @@ export default function PipeAnalysisPopupPage() {
|
||||
);
|
||||
|
||||
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
|
||||
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
|
||||
const pagedRows = useMemo(
|
||||
() =>
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? rows
|
||||
: rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
|
||||
[appliedFilters.analysisType, rows, summaryPageOffset],
|
||||
);
|
||||
const visibleYearMaterialGroups = useMemo(
|
||||
() =>
|
||||
yearMaterialGroups.slice(
|
||||
summaryPageOffset,
|
||||
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
|
||||
),
|
||||
[summaryPageOffset, yearMaterialGroups],
|
||||
);
|
||||
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? yearMaterialGroups.length
|
||||
: rows.length;
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
@@ -290,18 +316,18 @@ export default function PipeAnalysisPopupPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<main className="sn-popup-page">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">관로분석</h1>
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">관로분석</h1>
|
||||
</header>
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.ctpvCd}
|
||||
disabled={scope.ctpvLocked}
|
||||
onChange={(event) =>
|
||||
@@ -323,9 +349,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.sggCd}
|
||||
disabled={scope.sggLocked}
|
||||
onChange={(event) =>
|
||||
@@ -346,9 +372,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">처리구역</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.swtrmtZoneNm}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
@@ -366,9 +392,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">분석구분</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.analysisType}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
@@ -410,11 +436,8 @@ export default function PipeAnalysisPopupPage() {
|
||||
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
|
||||
<>
|
||||
<div className="card-header">
|
||||
<h3 className="hidden">
|
||||
寃쎄낵?꾨룄&?ъ쭏 愿濡쒗쁽?? 珥?{yearMaterialGroups.length.toLocaleString("ko-KR")}嫄?
|
||||
</h3>
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건
|
||||
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
|
||||
@@ -424,7 +447,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
{headList.map((head) => (
|
||||
<TableHead
|
||||
key={head.id}
|
||||
className={`border border-border bg-[#eaf3fb] px-4 py-[1.2rem] text-[1.3rem] font-medium text-[#647784] ${
|
||||
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
|
||||
head.align === "right" ? "text-right" : "text-center"
|
||||
}`}
|
||||
>
|
||||
@@ -434,8 +457,8 @@ export default function PipeAnalysisPopupPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{yearMaterialGroups.length > 0 ? (
|
||||
yearMaterialGroups.flatMap((group) =>
|
||||
{visibleYearMaterialGroups.length > 0 ? (
|
||||
visibleYearMaterialGroups.flatMap((group) =>
|
||||
group.rows.map((yearRow, rowIndex) => (
|
||||
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
|
||||
{rowIndex === 0 ? (
|
||||
@@ -467,7 +490,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
<TableCell
|
||||
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
|
||||
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
|
||||
isClickable ? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2" : ""
|
||||
isClickable ? "cursor-pointer font-semibold text-primary underline underline-offset-2" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
@@ -484,14 +507,24 @@ export default function PipeAnalysisPopupPage() {
|
||||
)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-gray-500">
|
||||
議고쉶??寃곌낵媛 ?놁뒿?덈떎.
|
||||
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-label">
|
||||
조회 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
|
||||
<div className="flex justify-center">
|
||||
<UiPageNations
|
||||
total={yearMaterialGroups.length}
|
||||
cur={summaryCurrentPage}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
onPageChange={setSummaryCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -499,21 +532,26 @@ export default function PipeAnalysisPopupPage() {
|
||||
className={appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? "hidden"
|
||||
: useHorizontalScrollTable
|
||||
? "[&>div.relative.w-full]:overflow-x-auto [&>div.relative.w-full]:overflow-y-visible"
|
||||
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
|
||||
: ""}
|
||||
headName={
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? "경과년도&재질 관로현황"
|
||||
: "관로분석현황"
|
||||
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
}
|
||||
tableData={rows}
|
||||
tableData={pagedRows}
|
||||
headlist={headList}
|
||||
pageSize={Math.max(rows.length, 1)}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
total={rows.length}
|
||||
cellClick={(index, columnId) => handleCellClick(index, columnId)}
|
||||
onPageChange={() => undefined}
|
||||
curPage={1}
|
||||
cellClick={(index, columnId) =>
|
||||
handleCellClick(
|
||||
typeof index === "number" ? index + summaryPageOffset : index,
|
||||
columnId,
|
||||
)}
|
||||
onPageChange={setSummaryCurrentPage}
|
||||
curPage={summaryCurrentPage}
|
||||
widthFixed={!useHorizontalScrollTable}
|
||||
isHideCount={true}
|
||||
cellClassName={(row, columnId) =>
|
||||
!row.__isNoZone && row.__cellFilters[columnId]
|
||||
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
|
||||
|
||||
@@ -46,6 +46,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
};
|
||||
|
||||
const { BIZ_SE_CD, BIZ_TY_CD } = useStore();
|
||||
const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim();
|
||||
const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim();
|
||||
|
||||
// 마운트 및 업데이트 시 API 호출
|
||||
|
||||
@@ -53,9 +55,18 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
if (featureData) {
|
||||
setFormData(featureData);
|
||||
}
|
||||
}, [featureData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestMngInstCd || !requestFcltMngNo) {
|
||||
setInspectionData([]);
|
||||
setCleaningData([]);
|
||||
setRepairData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchAll = async () => {
|
||||
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${featureData?.mng_inst_cd}&fcltMngNo=${featureData?.fclt_mng_no}&page=0&size=1`);
|
||||
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${requestMngInstCd}&fcltMngNo=${requestFcltMngNo}&page=0&size=1`);
|
||||
|
||||
let inspectionArr: operationDataType[] = [];
|
||||
let cleaningArr: operationDataType[] = [];
|
||||
@@ -104,8 +115,13 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
setCleaningData(cleaningArr);
|
||||
setRepairData(repairArr);
|
||||
};
|
||||
fetchAll();
|
||||
}, [featureData]);
|
||||
fetchAll().catch((error) => {
|
||||
console.error("운영 이력 조회 실패:", error);
|
||||
setInspectionData([]);
|
||||
setCleaningData([]);
|
||||
setRepairData([]);
|
||||
});
|
||||
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]);
|
||||
|
||||
// 폼 데이터가 변경될 때 호출될 핸들러
|
||||
const handleFormDataChange = (newFormData: any) => {
|
||||
@@ -160,4 +176,4 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
)
|
||||
}
|
||||
|
||||
export default MsSwagePpl
|
||||
export default MsSwagePpl
|
||||
|
||||
@@ -33,9 +33,9 @@ const metricGroups: {
|
||||
title: string;
|
||||
field: keyof SwtrmtPrcsZoneAnalysisMetric;
|
||||
}[] = [
|
||||
{ title: "평균매설년도(년)", field: "avgInstlYear" },
|
||||
{ title: "최장매설년도(년)", field: "oldestInstlYear" },
|
||||
{ title: "최신매설년도(년)", field: "latestInstlYear" },
|
||||
{ title: "평균경과년도(년)", field: "avgInstlYear" },
|
||||
{ title: "최장경과년도(년)", field: "oldestInstlYear" },
|
||||
{ title: "최신경과년도(년)", field: "latestInstlYear" },
|
||||
];
|
||||
|
||||
const MetricVerticalGrid = ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user