Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev

This commit is contained in:
2026-07-20 13:44:16 +09:00
357 changed files with 30078 additions and 4392 deletions
+2 -2
View File
@@ -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}
>
+30 -9
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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();
+2 -1
View File
@@ -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";
+63 -33
View File
@@ -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);
}
+13 -82
View File
@@ -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>;
}
+15 -33
View File
@@ -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 = () => {
-1
View File
@@ -97,4 +97,3 @@ export const ENDPOINT = {
};
export const JWT_EXPIRATION_TIME = 1800000;
+17
View File
@@ -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,
};
+4 -1
View File
@@ -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
View File
@@ -1,2 +1,3 @@
export * from "./api";
export * from "./auth";
export * from "./env";
-17
View File
@@ -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 });
}
+7 -2
View File
@@ -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");
+2
View File
@@ -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"
},
+20 -19
View File
@@ -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";
+1 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -6
View File
@@ -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
+14 -13
View File
@@ -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("");
+7 -2
View File
@@ -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)
}
}
+80 -5
View File
@@ -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 -24
View File
@@ -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();
}}
+6 -6
View File
@@ -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")) {
+5 -1
View File
@@ -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",
+4 -5
View File
@@ -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
View File
@@ -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
+6 -5
View File
@@ -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 = /
+8 -6
View File
@@ -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
+17
View File
@@ -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>
);
}
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/src/auth/login";
export default LoginPage;
+104 -91
View File
@@ -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;
}
-2
View File
@@ -1,2 +0,0 @@
import { LoginPage } from "@/src/features/auth/login";
export default LoginPage;
-12
View File
@@ -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)); // 공단 환경
}
}
+2 -2
View File
@@ -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";
+4 -1
View File
@@ -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;
+2 -2
View File
@@ -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}
>
+2 -2
View File
@@ -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 {
+6 -2
View File
@@ -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",
+156
View File
@@ -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();
+2 -1
View File
@@ -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";
+72 -52
View File
@@ -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);
}
+13 -107
View File
@@ -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";
+2 -1
View File
@@ -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 };
+30 -1
View File
@@ -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;
+17
View File
@@ -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",
};
+20 -4
View File
@@ -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;
+2
View File
@@ -1,2 +1,4 @@
export * from "./api";
export * from "./env";
export const DEFAULT_LIST_SIZE = 10;
+12
View File
@@ -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)
);
}
+7
View File
@@ -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;
}
+327
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
+4 -3
View File
@@ -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
+17
View File
@@ -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>
);
}
+2
View File
@@ -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,
};
};
@@ -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