diff --git a/apps/cm/.env.example b/apps/cm/.env.example index 5d9747b57..0da6c0f8a 100644 --- a/apps/cm/.env.example +++ b/apps/cm/.env.example @@ -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 diff --git a/apps/cm/app/(with-base-layout)/layout.tsx b/apps/cm/app/(with-base-layout)/layout.tsx index 481d8f30c..732769718 100644 --- a/apps/cm/app/(with-base-layout)/layout.tsx +++ b/apps/cm/app/(with-base-layout)/layout.tsx @@ -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 ( diff --git a/apps/cm/app/api/proxy/[...url]/route.ts b/apps/cm/app/api/proxy/[...url]/route.ts index 105b36fa7..c0f646cd2 100644 --- a/apps/cm/app/api/proxy/[...url]/route.ts +++ b/apps/cm/app/api/proxy/[...url]/route.ts @@ -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 }); } diff --git a/apps/cm/middleware.ts b/apps/cm/middleware.ts index 241df1e74..bae4e6579 100644 --- a/apps/cm/middleware.ts +++ b/apps/cm/middleware.ts @@ -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*", + "/", + ], }; diff --git a/apps/cm/package.json b/apps/cm/package.json index 5eaca0435..64eb7edb4 100644 --- a/apps/cm/package.json +++ b/apps/cm/package.json @@ -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" }, diff --git a/apps/cm/src/entities/auth/api/auth-service.ts b/apps/cm/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..ddb3dffac --- /dev/null +++ b/apps/cm/src/entities/auth/api/auth-service.ts @@ -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 { + 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 { + 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 { + 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, +}; diff --git a/apps/cm/src/entities/auth/api/login-service.ts b/apps/cm/src/entities/auth/api/login-service.ts deleted file mode 100644 index c1968fe36..000000000 --- a/apps/cm/src/entities/auth/api/login-service.ts +++ /dev/null @@ -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 { - 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 { - 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(); diff --git a/apps/cm/src/entities/auth/index.ts b/apps/cm/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/cm/src/entities/auth/index.ts +++ b/apps/cm/src/entities/auth/index.ts @@ -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"; diff --git a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx index 5366696f7..87f932817 100644 --- a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx @@ -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( - `${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( + `${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 }; +} diff --git a/apps/cm/src/entities/auth/lib/device-id.ts b/apps/cm/src/entities/auth/lib/device-id.ts new file mode 100644 index 000000000..3683c7189 --- /dev/null +++ b/apps/cm/src/entities/auth/lib/device-id.ts @@ -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); +} diff --git a/apps/cm/src/entities/auth/lib/token-manager.ts b/apps/cm/src/entities/auth/lib/token-manager.ts index 8fcf21984..78d24bd5e 100644 --- a/apps/cm/src/entities/auth/lib/token-manager.ts +++ b/apps/cm/src/entities/auth/lib/token-manager.ts @@ -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, - 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; diff --git a/apps/cm/src/entities/auth/model/login-interface.ts b/apps/cm/src/entities/auth/model/login-interface.ts deleted file mode 100644 index 5aaf53044..000000000 --- a/apps/cm/src/entities/auth/model/login-interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ILogin { - userId: string; - password: string; -} - -export interface LoginService { - login(payload: ILogin): Promise; - silentRefresh(): Promise; -} diff --git a/apps/cm/src/pages/auth/login/ui/OtpModal.tsx b/apps/cm/src/pages/auth/login/ui/OtpModal.tsx index 7c6aeb9af..39a343850 100644 --- a/apps/cm/src/pages/auth/login/ui/OtpModal.tsx +++ b/apps/cm/src/pages/auth/login/ui/OtpModal.tsx @@ -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) { diff --git a/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx index 8faf518f1..e4a542cfa 100644 --- a/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx +++ b/apps/cm/src/pages/auth/login/ui/UserSignupModal.tsx @@ -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 ? "가입 중..." : "회원가입"} diff --git a/apps/cm/src/pages/auth/login/ui/login-page.tsx b/apps/cm/src/pages/auth/login/ui/login-page.tsx index f7f613086..d01bc4867 100644 --- a/apps/cm/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cm/src/pages/auth/login/ui/login-page.tsx @@ -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) { diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx index 9d5e9179a..fe8a7f6c8 100644 --- a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -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 = () => { diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts index 3cdb96d0c..26d5b8e95 100644 --- a/apps/cm/src/shared/config/api.ts +++ b/apps/cm/src/shared/config/api.ts @@ -97,4 +97,3 @@ export const ENDPOINT = { }; -export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/cm/src/shared/config/auth.ts b/apps/cm/src/shared/config/auth.ts new file mode 100644 index 000000000..f743b9f61 --- /dev/null +++ b/apps/cm/src/shared/config/auth.ts @@ -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, +}; diff --git a/apps/cm/src/shared/config/env.ts b/apps/cm/src/shared/config/env.ts index c6373724f..4d8874f5c 100644 --- a/apps/cm/src/shared/config/env.ts +++ b/apps/cm/src/shared/config/env.ts @@ -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; diff --git a/apps/cm/src/shared/config/index.ts b/apps/cm/src/shared/config/index.ts index cd83447d5..ea6a6f16a 100644 --- a/apps/cm/src/shared/config/index.ts +++ b/apps/cm/src/shared/config/index.ts @@ -1,2 +1,3 @@ export * from "./api"; +export * from "./auth"; export * from "./env"; diff --git a/apps/cs/app/api/auth/logout/route.ts b/apps/cs/app/api/auth/logout/route.ts deleted file mode 100644 index fdcc9df7c..000000000 --- a/apps/cs/app/api/auth/logout/route.ts +++ /dev/null @@ -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 }); -} diff --git a/apps/cs/app/api/proxy/[...url]/route.ts b/apps/cs/app/api/proxy/[...url]/route.ts index 0929ca57c..4385956d3 100644 --- a/apps/cs/app/api/proxy/[...url]/route.ts +++ b/apps/cs/app/api/proxy/[...url]/route.ts @@ -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"); diff --git a/apps/cs/package.json b/apps/cs/package.json index b7e1421d8..710032627 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -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" }, diff --git a/apps/cs/src/entities/auth/api/auth-service.ts b/apps/cs/src/entities/auth/api/auth-service.ts index ef08d8ee1..cf7281ff9 100644 --- a/apps/cs/src/entities/auth/api/auth-service.ts +++ b/apps/cs/src/entities/auth/api/auth-service.ts @@ -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 { 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 { 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 { } async function logout(): Promise { - 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, }; diff --git a/apps/cs/src/entities/auth/lib/device-id.ts b/apps/cs/src/entities/auth/lib/device-id.ts new file mode 100644 index 000000000..3683c7189 --- /dev/null +++ b/apps/cs/src/entities/auth/lib/device-id.ts @@ -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); +} diff --git a/apps/cs/src/entities/auth/lib/token-manager.ts b/apps/cs/src/entities/auth/lib/token-manager.ts index 89f0c7371..78d24bd5e 100644 --- a/apps/cs/src/entities/auth/lib/token-manager.ts +++ b/apps/cs/src/entities/auth/lib/token-manager.ts @@ -8,6 +8,7 @@ export const { setToken, removeToken, setupRefreshTimer, + clearRefreshTimer, getAccessToken, getRefreshToken, getUserId, diff --git a/apps/cs/src/entities/auth/model/auth-interface.ts b/apps/cs/src/entities/auth/model/auth-interface.ts deleted file mode 100644 index 97078052e..000000000 --- a/apps/cs/src/entities/auth/model/auth-interface.ts +++ /dev/null @@ -1 +0,0 @@ -export type { AuthPayload, AuthService } from "@common/auth"; diff --git a/apps/cs/tsconfig.json b/apps/cs/tsconfig.json index 5b8c29899..05cbfce70 100644 --- a/apps/cs/tsconfig.json +++ b/apps/cs/tsconfig.json @@ -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": [ diff --git a/apps/dm/.env.local b/apps/dm/.env.local index 4cef9d965..5d9ffae06 100644 --- a/apps/dm/.env.local +++ b/apps/dm/.env.local @@ -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 diff --git a/apps/dm/.env.mindone b/apps/dm/.env.mindone index 1a11ec6a9..12ca17853 100644 --- a/apps/dm/.env.mindone +++ b/apps/dm/.env.mindone @@ -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 diff --git a/apps/dm/.env.production b/apps/dm/.env.production index b09c7da8a..fbe11a3a9 100644 --- a/apps/dm/.env.production +++ b/apps/dm/.env.production @@ -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 diff --git a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx index 86262be44..fb893c8fb 100644 --- a/apps/dm/app/(with-layout)/apply/dmdco/page.tsx +++ b/apps/dm/app/(with-layout)/apply/dmdco/page.tsx @@ -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(""); diff --git a/apps/dm/app/store/index.tsx b/apps/dm/app/store/index.tsx index 47170b3db..4291adfba 100644 --- a/apps/dm/app/store/index.tsx +++ b/apps/dm/app/store/index.tsx @@ -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 ( {children} @@ -39,4 +44,4 @@ export const useDmStore = ( } return useStore(storeContext, selector) -} \ No newline at end of file +} diff --git a/apps/dm/app/store/sdmMng/index.tsx b/apps/dm/app/store/sdmMng/index.tsx index 0041f5a93..904c0a6b0 100644 --- a/apps/dm/app/store/sdmMng/index.tsx +++ b/apps/dm/app/store/sdmMng/index.tsx @@ -43,6 +43,48 @@ export const sdmInfoFormSchema = z.object({ export type SdmInfoFormData = z.infer; +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 = } } -const postSdmLoginData = async (data: DemandCompType) => { +const postSdmLoginData = async (data: DemandCompType): Promise => { 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; insertSdmInfoData: (data: SdmInfoFormData) => Promise; updateSdmInfoData: (data: SdmInfoFormData) => Promise; getDemandCompList: (searchText: string, pageCnt: number) => Promise; - actionLogin: (data: DemandCompType) => Promise; + actionLogin: (data: DemandCompType) => Promise; getSdmInfoStatusList: (dmdInfoId: string) => Promise; getSimilarCaseList: (part: string, value: number | string) => Promise; actionLogout: () => Promise; @@ -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 => { 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, } })) }, diff --git a/apps/dm/components/TopNav.tsx b/apps/dm/components/TopNav.tsx index 5e24ead78..741dbed2a 100644 --- a/apps/dm/components/TopNav.tsx +++ b/apps/dm/components/TopNav.tsx @@ -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[] = [ { 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(null); const [topMenus, setTopMenus] = useState([]); - 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 ( + +
+
쿠키명
+
{CLAIM_NAME}
+
+ +
+
exp
+
{jwtState.exp ?? "-"}
+
+ +
+
만료시각
+
{jwtState.expDateLocal ?? "-"}
+
+ +
+
남은시간
+
{formatRemainingMs(jwtState.remainingMs)}
+
+ +
+
상태
+
{jwtState.error ?? "정상"}
+
+ +
+
payload
+
+          {jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
+        
+
+ + ); +} diff --git a/apps/fo/app/pages/(with-layout)/layout.tsx b/apps/fo/app/pages/(with-layout)/layout.tsx index 38276b541..768d66670 100644 --- a/apps/fo/app/pages/(with-layout)/layout.tsx +++ b/apps/fo/app/pages/(with-layout)/layout.tsx @@ -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 ( diff --git a/apps/fo/middleware.ts b/apps/fo/middleware.ts index f9891ff7d..e3ee26b31 100644 --- a/apps/fo/middleware.ts +++ b/apps/fo/middleware.ts @@ -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 { diff --git a/apps/fo/package.json b/apps/fo/package.json index 4dd6c3e3c..84b192049 100644 --- a/apps/fo/package.json +++ b/apps/fo/package.json @@ -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", diff --git a/apps/fo/src/features/auth/login/index.ts b/apps/fo/src/auth/login/index.ts similarity index 100% rename from apps/fo/src/features/auth/login/index.ts rename to apps/fo/src/auth/login/index.ts diff --git a/apps/fo/src/auth/login/ui/login-page.tsx b/apps/fo/src/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..6398700b9 --- /dev/null +++ b/apps/fo/src/auth/login/ui/login-page.tsx @@ -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(""); + + const handleSubmit = async (e: React.SubmitEvent) => { + 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 ( +
+
+
+

+ 유역하수도통합플랫폼 +
+ 하수도통계 관리시스템 +

+

+ 유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을 + 환영합니다. +

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+ {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} +
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 + 계) 032-590-3723, 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; diff --git a/apps/wr/src/features/auth/login/ui/login.css b/apps/fo/src/auth/login/ui/login.css similarity index 94% rename from apps/wr/src/features/auth/login/ui/login.css rename to apps/fo/src/auth/login/ui/login.css index af73c9c68..e65180ae7 100644 --- a/apps/wr/src/features/auth/login/ui/login.css +++ b/apps/fo/src/auth/login/ui/login.css @@ -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; diff --git a/apps/fo/src/entities/auth/api/auth-service.ts b/apps/fo/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..2ea6bab12 --- /dev/null +++ b/apps/fo/src/entities/auth/api/auth-service.ts @@ -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 { + 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 { + 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 { + tokenManager.clearAuthToken(); + clearDeviceId(); + + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} + +export const authService: AuthService = { + login, + silentRefresh, + declineSessionExtend: clearRefreshTimer, + logout, +}; diff --git a/apps/fo/src/entities/auth/api/login-service.ts b/apps/fo/src/entities/auth/api/login-service.ts deleted file mode 100644 index 760858b99..000000000 --- a/apps/fo/src/entities/auth/api/login-service.ts +++ /dev/null @@ -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 { - 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 { - 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(); diff --git a/apps/fo/src/entities/auth/index.ts b/apps/fo/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/fo/src/entities/auth/index.ts +++ b/apps/fo/src/entities/auth/index.ts @@ -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"; diff --git a/apps/fo/src/entities/auth/lib/auth-wrapper.tsx b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx index 21fd87180..0dc0661ea 100644 --- a/apps/fo/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/fo/src/entities/auth/lib/auth-wrapper.tsx @@ -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( - `${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( + `${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 }; diff --git a/apps/fo/src/entities/auth/lib/device-id.ts b/apps/fo/src/entities/auth/lib/device-id.ts new file mode 100644 index 000000000..f844fcbef --- /dev/null +++ b/apps/fo/src/entities/auth/lib/device-id.ts @@ -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); +} diff --git a/apps/fo/src/entities/auth/lib/token-manager.ts b/apps/fo/src/entities/auth/lib/token-manager.ts index 5908dd091..172f2cc3a 100644 --- a/apps/fo/src/entities/auth/lib/token-manager.ts +++ b/apps/fo/src/entities/auth/lib/token-manager.ts @@ -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, - 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; diff --git a/apps/fo/src/entities/menu-authority/index.ts b/apps/fo/src/entities/menu-authority/index.ts index 2a555b77e..d5991d01c 100644 --- a/apps/fo/src/entities/menu-authority/index.ts +++ b/apps/fo/src/entities/menu-authority/index.ts @@ -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"; diff --git a/apps/fo/src/entities/menu/index.ts b/apps/fo/src/entities/menu/index.ts index 04f63e38b..2bd1b1787 100644 --- a/apps/fo/src/entities/menu/index.ts +++ b/apps/fo/src/entities/menu/index.ts @@ -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"; diff --git a/apps/fo/src/features/auth/login/ui/login-page.tsx b/apps/fo/src/features/auth/login/ui/login-page.tsx deleted file mode 100644 index fe14819f3..000000000 --- a/apps/fo/src/features/auth/login/ui/login-page.tsx +++ /dev/null @@ -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) => { - 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 ( -
-
-
-

- 하수도정보시스템 -
- CMS 통합관리시스템 -

-

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

-
-
- logo -
-
-
-
-
    -
  • -

    ID

    -
    - - -
    -
  • -
  • -

    PASSWORD

    -
    - - -
    -
  • -
-
-
- - -
- {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} -
- -
-
-

copyright © 2023 KECO. All rights reserved.

-
- -

- - 주소 : (22689) 인천광역시 서구 환경로 42(경서동 - 종합환경연구단지) - - - 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, - 3722 - - 팩스 :032-590-3729 -

-
-
- -
- ); -} - -export { LoginPage }; - diff --git a/apps/fo/src/shared/config/api.ts b/apps/fo/src/shared/config/api.ts index 3eba9fa91..eebb15c19 100644 --- a/apps/fo/src/shared/config/api.ts +++ b/apps/fo/src/shared/config/api.ts @@ -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; diff --git a/apps/fo/src/shared/config/auth.ts b/apps/fo/src/shared/config/auth.ts new file mode 100644 index 000000000..7c35ad9f0 --- /dev/null +++ b/apps/fo/src/shared/config/auth.ts @@ -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", +}; diff --git a/apps/fo/src/shared/config/env.ts b/apps/fo/src/shared/config/env.ts index 6e3bf223e..a198051e8 100644 --- a/apps/fo/src/shared/config/env.ts +++ b/apps/fo/src/shared/config/env.ts @@ -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; + diff --git a/apps/fo/src/shared/config/index.ts b/apps/fo/src/shared/config/index.ts index cd83447d5..04e4f2df5 100644 --- a/apps/fo/src/shared/config/index.ts +++ b/apps/fo/src/shared/config/index.ts @@ -1,2 +1,4 @@ export * from "./api"; export * from "./env"; + +export const DEFAULT_LIST_SIZE = 10; diff --git a/apps/fo/src/shared/lib/bbs-notice-menu.ts b/apps/fo/src/shared/lib/bbs-notice-menu.ts new file mode 100644 index 000000000..a621ed669 --- /dev/null +++ b/apps/fo/src/shared/lib/bbs-notice-menu.ts @@ -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) + ); +} diff --git a/apps/fo/src/shared/lib/index.ts b/apps/fo/src/shared/lib/index.ts index b8b4c52c0..1d0c4a3a8 100644 --- a/apps/fo/src/shared/lib/index.ts +++ b/apps/fo/src/shared/lib/index.ts @@ -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"; diff --git a/apps/fo/src/shared/lib/statistics-menu-url.ts b/apps/fo/src/shared/lib/statistics-menu-url.ts new file mode 100644 index 000000000..c20dded4c --- /dev/null +++ b/apps/fo/src/shared/lib/statistics-menu-url.ts @@ -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; +} diff --git a/apps/fo/src/store/tab-store.ts b/apps/fo/src/store/tab-store.ts new file mode 100644 index 000000000..0379b3601 --- /dev/null +++ b/apps/fo/src/store/tab-store.ts @@ -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 { + try { + const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd); + apply(response?.[0] ?? null); + } catch { + apply(null); + } +} + +function scheduleTabPageMenuAuthorityFetch( + get: () => TabStore, + set: ( + partial: + | Partial + | ((state: TabStore) => Partial), + ) => 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 + | ((state: TabStore) => Partial), +) => 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()( + 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 + > | 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(); +} diff --git a/apps/sn/.env.local b/apps/sn/.env.local index 33b8ef842..337c819d2 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -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 diff --git a/apps/sn/.env.mindone b/apps/sn/.env.mindone index dc567fea8..8a39a81a8 100644 --- a/apps/sn/.env.mindone +++ b/apps/sn/.env.mindone @@ -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 \ No newline at end of file diff --git a/apps/sn/.env.production b/apps/sn/.env.production index 0b8189a78..f60cba1a1 100644 --- a/apps/sn/.env.production +++ b/apps/sn/.env.production @@ -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 \ No newline at end of file diff --git a/apps/sn/app/(auth)/auth/layout.tsx b/apps/sn/app/(auth)/auth/layout.tsx new file mode 100644 index 000000000..42f63e71a --- /dev/null +++ b/apps/sn/app/(auth)/auth/layout.tsx @@ -0,0 +1,17 @@ +import "../../../src/app/styles/base.css"; + +export const metadata = { + title: "유역하수도 통합 정보플랫폼", +}; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/sn/app/(auth)/auth/login/page.tsx b/apps/sn/app/(auth)/auth/login/page.tsx new file mode 100644 index 000000000..8b642ac57 --- /dev/null +++ b/apps/sn/app/(auth)/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/auth/login"; +export default LoginPage; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx index 9162074f4..c4c3db13c 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -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 | Promise; 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 = ; + + const settingRow = ( +
+
+ {indicator} + +
+
+ event.currentTarget.select()} + onChange={(event) => { + const digits = event.target.value.replace(/[^0-9]/g, ""); + setElapsedYearsText(digits); + }} + onKeyDown={(event) => { + if (event.key === "Enter") void commitElapsedYears(); + }} + /> + +
+
+ ); + + if (compact) { + return ( +
+ {settingRow} +
+ ); + } + return ( -
+
노후관로 설정
{!isCollapsed && (
-
- - event.currentTarget.select()} - onChange={(event) => { - const digits = event.target.value.replace(/[^0-9]/g, ""); - setElapsedYearsText(digits); - }} - onKeyDown={(event) => { - if (event.key === "Enter") commitElapsedYears(); - }} - /> - -
+ {settingRow}
)}
diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index 1b6542816..c900fe2c2 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -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 ( -
+
-
-

노후관로 현황

+
+

노후관로 현황

{downloadProgressText ? ( -
+
{downloadProgressText}
) : null} -
-
-
); } diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts index e273d9c1c..8419e929c 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -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 = [ diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 53cf75946..51aff4d1b 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -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) => ( + +); + 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(null); + const [activeKey, setActiveKey] = useState(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 (
{isOpen ? ( -
-
+
+
- + 분석도구
@@ -53,7 +111,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
{ANALYSIS_POPUP_ITEMS.map((item) => { const Icon = item.icon; - const isActive = activeKey === item.key; + const isActive = item.key === "gisEditPopup" + ? isGisEditActive + : activeKey === item.key; return ( ); })} @@ -90,7 +171,7 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro +
+ +
+
+ {[ + ["year", "년도탭"], + ["disaster", "재해명탭"], + ["rainfall", "침수발생강우탭"], + ].map(([tabId, label]) => { + const isActive = activeTab === tabId; + + return ( + + ); + })} +
+
+ +
+
+ {renderRows(currentRows)} +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx new file mode 100644 index 000000000..f9ae60995 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx @@ -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(FLOOD_HISTORY_DEFAULT_FILTERS); + // 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다. + const [appliedFilters, setAppliedFilters] = useState(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 ( +
+
+

침수흔적도 목록

+
+
+ +
+
+
+ + + + + + + + +
+ + +
+
+
+ +
+
+

+ 침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건 +

+
+ +
+ + + {GRID_COLUMNS.map((head) => ( + + ))} + + + + NO + 시도 + 시군구 + 강우 분석정보 + 침수재해명 + 침수년도 + 침수시간 + 침수지구명 + 침수원인상세코드 + 침수원인상세 + 평균침수위 + 침수면적 + 조사년도 + 피해금액 + + + 침수시작일자 + 침수종료일자 + 침수시작시간 + 침수종료시간 + + + + {pagedRows.length > 0 ? ( + pagedRows.map((row) => { + const isSelected = selectedHistoryId === row.historyId; + + return ( + handleRowClick(row)} + onDoubleClick={() => handleRowDoubleClick(row)} + > + {row.rowNo} + {row.ctpvNm} + {row.sggNm} + {row.startDateDisplay} + {row.endDateDisplay} + {row.startTimeDisplay} + {row.endTimeDisplay} + {row.disasterName} + {row.floodYearDisplay} + {row.floodHourDisplay} + {row.districtNameDisplay} + {row.causeCodeDisplay} + {row.causeDetailDisplay} + {row.avgFloodDepthDisplay} + {row.floodAreaDisplay} + {row.exmnYearDisplay} + {row.damageAmountDisplay} + + ); + }) + ) : ( + + + 조회된 데이터가 없습니다. + + + )} + +
+
+ + {filteredRows.length > 0 ? ( + + ) : null} + +
+ 행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다. +
+
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts b/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts new file mode 100644 index 000000000..fd6ef484e --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts @@ -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 = { + 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 & { + 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; +}; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx new file mode 100644 index 000000000..591077005 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx @@ -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, field: { id: string; sourceId?: string }) => + values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined); + +const pickFields = (values: Record, fieldIds: string[]) => + fieldIds.reduce>((acc, fieldId) => { + if (Object.prototype.hasOwnProperty.call(values, fieldId)) { + acc[fieldId] = values[fieldId]; + } + return acc; + }, {}); + +const normalizeBasicFormValues = (values: Record) => { + 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>((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>({}); + const [inputInfoTableData, setInputInfoTableData] = useState>( + buildInitialInputDraft(change), + ); + const [mngInstOptions, setMngInstOptions] = useState([]); + + 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 = { + ...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, + nextInputInfoTableData: Record, + ): 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) => { + setBasicInfoTableData(nextFormData); + onSave(change.changeId, buildMergedDraft(nextFormData, inputInfoTableData)); + }; + + const handleInputInfoChange = (nextFormData: Record) => { + 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 ( + + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx new file mode 100644 index 000000000..4978ce8bd --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx @@ -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 = { + 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) => ( + + ))} + +); + +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 ( +
+
+
+ {variant === "inline" ? ( +
편집 세션
+ ) : null} +
+ 레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"} +
+
+ {onOpenPopup ? ( + + ) : null} +
+ +
+ + + +
+ 총 {changes.length}건 +
+
+ +
+ {changes.length === 0 ? ( +
+ 저장할 편집 변경분이 없습니다. +
+ ) : ( +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx new file mode 100644 index 000000000..593e8eb02 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx @@ -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 ( +
+
+
+
+ 하수관로 편집 +
+
+
+ {TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => { + const isActive = activeTool === tool; + const isToolEnabled = + canUseTools && allowedTools.includes(tool); + if (tool === "VERTEX_EDIT") { + return ( +
+ + +
+ ); + } + return ( + + ); + })} +
+
+ +
+ + + +
+
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts new file mode 100644 index 000000000..930e48c07 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -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 | null; + afterGeometry?: Record | null; + featureProperties?: Record | 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 | null; + afterGeometry?: Record | null; + geometry?: Record | null; + }>; +}; + +export type GisEditSessionSnapshot = { + isEditMode: boolean; + selectedLayerName: string; + activeTool: GisEditTool | null; + isSnapEnabled: boolean; + changes: GisEditChange[]; + lastSavedPayload: GisEditSavePayload | null; + canUndo: boolean; + focusedChangeId: string | null; +}; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts new file mode 100644 index 000000000..a27bcc6b5 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -0,0 +1,1237 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import GeoJSON from "ol/format/GeoJSON.js"; + +import type { + GisEditChange, + GisEditFeatureRef, + GisEditGeometryType, + GisEditLayerInfo, + GisEditPipeBasicInfo, + GisEditPreviewColors, + GisEditSavePayload, + GisEditTool, +} from "./gisEditTypes"; + +type GisEditInteractionRequest = { + changeId: string; + editType: "MOVE" | "VERTEX_EDIT"; +}; + +const isActionableChange = (change: GisEditChange) => + !change.attributeOnly || Boolean(change.basicInfoSavedAt); + +const isGeometryChange = (change: GisEditChange) => + !change.attributeOnly && change.editType !== "ADD"; + +const buildPreviewFocusMessage = ( + change: Pick, +) => { + if (change.editType === "MOVE") { + return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`; + } + if (change.editType === "VERTEX_EDIT") { + return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`; + } + if (change.editType === "DELETE") { + return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`; + } + return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`; +}; + +const EDITABLE_LAYER_RULES: Record< + string, + Omit +> = { + tb_ms_swage_ppl: { + layerName: "하수관로", + geometryType: "LineString", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(22, 163, 74, 0.92)", + lineColor: "rgba(22, 163, 74, 0.95)", + fillColor: "rgba(22, 163, 74, 0.18)", + }, + }, + tb_ms_swage_mhl: { + layerName: "하수맨홀", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(5, 150, 105, 0.92)", + lineColor: "rgba(5, 150, 105, 0.95)", + fillColor: "rgba(5, 150, 105, 0.18)", + }, + }, + tb_ms_swage_pmstn: { + layerName: "하수펌프장", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(59, 130, 246, 0.92)", + lineColor: "rgba(59, 130, 246, 0.95)", + fillColor: "rgba(59, 130, 246, 0.18)", + }, + }, + tb_il_swage_prcs_fclt: { + layerName: "하수처리시설", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(168, 85, 247, 0.92)", + lineColor: "rgba(168, 85, 247, 0.95)", + fillColor: "rgba(168, 85, 247, 0.18)", + }, + }, + tb_ms_swage_prcs_zone: { + layerName: "하수처리구역", + geometryType: "Polygon", + allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"], + previewColors: { + pointColor: "rgba(249, 115, 22, 0.92)", + lineColor: "rgba(249, 115, 22, 0.95)", + fillColor: "rgba(249, 115, 22, 0.22)", + }, + }, + tb_ms_stofcbr: { + layerName: "우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(234, 88, 12, 0.92)", + lineColor: "rgba(234, 88, 12, 0.95)", + fillColor: "rgba(234, 88, 12, 0.18)", + }, + }, + om_dt_untrt_swg_rain: { + layerName: "중점우수토실", + geometryType: "Point", + allowedTools: ["ADD", "MOVE", "DELETE"], + previewColors: { + pointColor: "rgba(220, 38, 38, 0.92)", + lineColor: "rgba(220, 38, 38, 0.95)", + fillColor: "rgba(220, 38, 38, 0.18)", + }, + }, +}; + +const FLOW_DIRECTION_SRS = "EPSG:5179"; +const SCREEN_ID = "SN_MN010301"; +const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]); +const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [ + "mngInstCd", + "fcltMngNo", + "instMngNo", + "instlYmd", + "mapshtNo", + "pdmtVl", + "prlgVl", + "usgCd", + "mqltCd", + "avgGrdn", + "drtwMltvArea", + "exclnMltvArea", + "tmptDpth", + "tmptMhlMngNo", + "scendDpth", + "scendMhlMngNo", + "trflnCnt", + "vrtcLen", + "wdthLen", + "instlMbdyCd", + "swtrmtZoneNm", + "mltvZoneNm", + "mltvDtdvNm", + "lineCd", + "floCd", + "layCd", +] as const; + +const buildPipeSaveAttributes = (basicInfo: GisEditPipeBasicInfo | null | undefined) => { + if (!basicInfo) return undefined; + + return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce((acc, fieldId) => { + if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc; + + const value = basicInfo[fieldId]; + const normalizedValue = fieldId === "instlYmd" ? String(value ?? "").replaceAll("-", "") : value; + acc[fieldId] = + typeof normalizedValue === "string" && normalizedValue.trim() === "" + ? null + : normalizedValue; + return acc; + }, {}); +}; + +export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = { + mngInstCd: "", + fcltMngNo: "", + instMngNo: "", + instlYmd: "", + usgCd: "", + instlMbdyCd: "", + swtrmtZoneNm: "", + mltvZoneNm: "", + mltvDtdvNm: "", + lineCd: "", + floCd: "", + layCd: "", +}; + +const normalizeLayerIdCandidate = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + return withoutNamespace.split(".")[0] ?? withoutNamespace; +}; + +// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다. +const getLayerTypeName = (layerInfo: any) => + String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim(); + +const getLayerName = (layerInfo: any) => + String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim(); + +const normalizeGeometryType = (value: unknown): GisEditGeometryType => { + const normalized = String(value ?? "").toLowerCase(); + if (normalized.includes("polygon")) return "Polygon"; + if (normalized.includes("line")) return "LineString"; + return "Point"; +}; + +const getColorString = (value: unknown) => { + const resolved = String(value ?? "").trim(); + return resolved || null; +}; + +const withAlpha = (color: string | null | undefined, alpha: number) => { + const resolvedColor = String(color ?? "").trim(); + if (!resolvedColor) return null; + + const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i); + if (rgbaMatch) { + const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "") + .split(",") + .map((part) => part.trim()); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1] ?? ""; + const normalized = hex.length === 3 + ? hex.split("").map((char) => char + char).join("") + : hex; + const red = parseInt(normalized.slice(0, 2), 16); + const green = parseInt(normalized.slice(2, 4), 16); + const blue = parseInt(normalized.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; + } + + return resolvedColor; +}; + +const resolvePreviewColors = ( + layerInfo: any, + fallback: GisEditPreviewColors, +): GisEditPreviewColors => { + const lineColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.lineColor ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr, + ), + 0.95, + ) ?? fallback.lineColor; + const pointColor = + withAlpha( + getColorString( + layerInfo?.lineColr ?? + layerInfo?.line_colr ?? + layerInfo?.fontInsdColr ?? + layerInfo?.font_insd_colr ?? + layerInfo?.fontExtnlColr ?? + layerInfo?.font_extnl_colr, + ), + 0.92, + ) ?? fallback.pointColor; + const fillColor = + withAlpha( + getColorString( + layerInfo?.spacColr ?? + layerInfo?.spac_colr ?? + layerInfo?.spaceColor ?? + layerInfo?.lineColr ?? + layerInfo?.line_colr, + ), + 0.22, + ) ?? fallback.fillColor; + + return { + pointColor, + lineColor, + fillColor, + }; +}; + +// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다. +const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { + const results = new Map(); + + const visit = (items: any[]) => { + items.forEach((item) => { + const data = item?.data ?? item; + const layerTypeName = getLayerTypeName(data); + const layerRule = EDITABLE_LAYER_RULES[layerTypeName]; + + if ( + layerRule && + GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) && + !results.has(layerTypeName) + ) { + results.set(layerTypeName, { + layerId: layerTypeName, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: resolvePreviewColors(data, layerRule.previewColors), + }); + } + + if (Array.isArray(item?.children)) { + visit(item.children); + } + if (Array.isArray(data?.children)) { + visit(data.children); + } + }); + }; + + visit(Array.isArray(treeData) ? treeData : []); + + if (results.size === 0) { + Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => { + if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return; + results.set(layerId, { + layerId, + layerName: layerRule.layerName, + geometryType: layerRule.geometryType, + editableYn: "Y", + allowedTools: layerRule.allowedTools, + previewColors: layerRule.previewColors, + }); + }); + } + + return Array.from(results.values()); +}; + +// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다. +const toGeometryObject = (featureData: any) => { + if (!featureData || typeof featureData.getGeometry !== "function") return null; + + try { + return new GeoJSON().writeGeometryObject(featureData.getGeometry(), { + featureProjection: FLOW_DIRECTION_SRS, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + } catch (error) { + console.error("GIS 편집 geometry 변환 실패:", error); + return null; + } +}; + +// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다. +const buildBbox = (map: any) => { + try { + const size = map?.getSize?.(); + const view = map?.getView?.(); + if (!size || !view?.calculateExtent) return null; + return view.calculateExtent(size).map((value: number) => value.toFixed(3)).join(","); + } catch (error) { + console.error("GIS 편집 BBOX 계산 실패:", error); + return null; + } +}; + +const getFeatureLayerId = (featureData: any) => { + if (typeof featureData?.get === "function") { + const previewLayerId = normalizeLayerIdCandidate(featureData.get("layerId")); + if (previewLayerId) return previewLayerId; + } + if (typeof featureData?.getId === "function") { + return normalizeLayerIdCandidate(featureData.getId()); + } + if (typeof featureData?.id_ === "string") { + return normalizeLayerIdCandidate(featureData.id_); + } + return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? ""); +}; + +const stripLayerPrefix = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) { + return withoutNamespace.slice("tb_ms_swage_ppl.".length); + } + return withoutNamespace; +}; + +const getFeatureFid = (featureData: any) => + stripLayerPrefix( + featureData?.get?.("fid") ?? + featureData?.values_?.fid ?? + featureData?.fid ?? + featureData?.getId?.() ?? + featureData?.id_ ?? + "", + ); + +const getFeatureObjectId = (featureData: any) => + String( + getFeatureFid(featureData) || + String( + featureData?.get?.("objectId") ?? + featureData?.values_?.fclt_mng_no ?? + featureData?.values_?.swtrmt_fclt_cd ?? + featureData?.fcltMngNo ?? + featureData?.fid ?? + "", + ), + ).trim(); + +const getFeatureObjectLabel = (featureData: any) => { + const values = featureData?.values_ ?? {}; + return String( + featureData?.get?.("fclt_mng_no") ?? + values?.fclt_mng_no ?? + featureData?.fcltMngNo ?? + featureData?.get?.("objectId") ?? + values?.swtrmt_fclt_cd ?? + getFeatureFid(featureData) ?? + "", + ).trim(); +}; + +const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { + const mapValues = featureData?.values_ ?? {}; + const featureRef: GisEditFeatureRef = { + fcltMngNo: String( + featureData?.get?.("fclt_mng_no") ?? + mapValues?.fclt_mng_no ?? + featureData?.fcltMngNo ?? + "", + ).trim() || undefined, + mngInstCd: String( + featureData?.get?.("mng_inst_cd") ?? + mapValues?.mng_inst_cd ?? + featureData?.mngInstCd ?? + "", + ).trim() || undefined, + fid: getFeatureFid(featureData) || undefined, + }; + + if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) { + return undefined; + } + + return featureRef; +}; + +// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다. +const getFeatureProperties = (featureData: any): Record | null => { + const values = featureData?.values_ ?? featureData?.getProperties?.() ?? featureData; + if (!values || typeof values !== "object") return null; + + const { geometry, ...properties } = values; + return properties; +}; + +const normalizePipeGeometry = ( + layerId: string, + geometry: Record | null, +): Record | null => { + if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry; + if (geometry.type !== "LineString") return geometry; + + const coordinates = geometry.coordinates; + if (!Array.isArray(coordinates)) return geometry; + + return { + ...geometry, + type: "MultiLineString", + coordinates: [coordinates], + }; +}; + +const createChangeId = (prefix: string, sequence: number) => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Date.now()}-${sequence}`; +}; + +export const useGisEdit = (treeData: any[]) => { + const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]); + // GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다. + const [isEditMode, setIsEditMode] = useState(false); + // 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다. + const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl"); + // 1차 구현에서는 activeTool과 지도 선택 이벤트를 조합해 변경분을 로컬 세션에 누적한다. + const [activeTool, setActiveTool] = useState(null); + // QGIS 유사 스냅핑 on/off는 MOVE, VERTEX_EDIT 상호작용에서 함께 참조한다. + const [isSnapEnabled, setIsSnapEnabled] = useState(false); + // 저장 전까지의 변경분은 FE 로컬 편집 세션으로만 유지한다. + const [changes, setChanges] = useState([]); + const [statusMessage, setStatusMessage] = useState( + "GIS편집 버튼을 켜면 하수관로 편집 도구를 사용할 수 있습니다.", + ); + // 이전 구현 호환용 상태다. 저장 버튼은 payload 미리보기를 노출하지 않고 즉시 API 호출 흐름으로 넘어간다. + const [lastSavedPayload, setLastSavedPayload] = useState(null); + const [pendingInteraction, setPendingInteraction] = useState(null); + const [focusedChangeId, setFocusedChangeId] = useState(null); + const changeSequenceRef = useRef(0); + const latestSelectionRef = useRef<{ signature: string; timestamp: number } | null>(null); + + const selectedLayer = useMemo( + () => editableLayers.find((layer) => layer.layerId === selectedLayerId) ?? null, + [editableLayers, selectedLayerId], + ); + + const canUseTools = Boolean(selectedLayer); + const actionableChanges = useMemo( + () => changes.filter(isActionableChange), + [changes], + ); + const canSave = actionableChanges.length > 0 && Boolean(selectedLayer); + + const appendChange = useCallback((change: GisEditChange) => { + setLastSavedPayload(null); + setFocusedChangeId(change.changeId); + setChanges((previous) => [change, ...previous]); + }, []); + + const focusChange = useCallback((changeId: string) => { + const targetChange = changes.find((change) => change.changeId === changeId); + if (!targetChange) return; + + setFocusedChangeId(changeId); + setPendingInteraction(null); + setStatusMessage(buildPreviewFocusMessage(targetChange)); + }, [changes]); + + const findExistingObjectChange = useCallback(( + editType: GisEditTool, + layerId: string, + objectId: string, + ) => + changes.find( + (change) => + !change.attributeOnly && + change.editType === editType && + change.layerId === layerId && + String(change.objectId ?? "") === objectId, + ) ?? null, + [changes]); + + // 같은 원본 관로에 대한 삭제/이동/꼭지점편집 변경분은 하나만 유지해 도구 전환 시 세션 상태가 섞이지 않게 한다. + const findExistingGeometryChange = useCallback(( + layerId: string, + objectId: string, + ) => + changes.find( + (change) => + isGeometryChange(change) && + change.layerId === layerId && + String(change.objectId ?? "") === objectId, + ) ?? null, + [changes]); + + // 편집 모드를 끄더라도 작업 중 세션을 잃지 않도록 변경분은 유지하고 도구 상태만 정리한다. + const toggleEditMode = useCallback(() => { + setIsEditMode((previous) => { + const next = !previous; + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + setIsSnapEnabled(false); + latestSelectionRef.current = null; + setStatusMessage( + next + ? "하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요." + : "GIS 편집 모드를 종료했습니다. 편집 세션은 유지됩니다.", + ); + return next; + }); + }, []); + + const toggleSnapEnabled = useCallback(() => { + setIsSnapEnabled((previous) => { + const next = !previous; + setStatusMessage( + next + ? "스냅핑을 켰습니다. 이동과 꼭지점편집 중 기존 하수관로 꼭지점/선분에 맞춰집니다." + : "스냅핑을 껐습니다. 이동과 꼭지점편집이 자유 좌표 기준으로 동작합니다.", + ); + return next; + }); + }, []); + + const selectLayer = useCallback((layerId: string) => { + setSelectedLayerId(layerId); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + latestSelectionRef.current = null; + setLastSavedPayload(null); + if (!layerId) { + setStatusMessage("편집 대상 레이어를 선택하세요."); + return; + } + + const layer = editableLayers.find((item) => item.layerId === layerId); + if (!layer) { + setStatusMessage("선택한 레이어 정보를 찾을 수 없습니다."); + return; + } + + setStatusMessage( + `${layer.layerName} 레이어를 편집 대상으로 설정했습니다. 추가 또는 기존 객체 선택 기반 편집을 진행하세요.`, + ); + }, [editableLayers]); + + // ADD는 즉시 로컬 초안을 만들고, 나머지 도구는 다음 지도 선택을 편집 세션 추가 이벤트로 해석한다. + const activateTool = useCallback((tool: GisEditTool, map: any) => { + if (!selectedLayer) { + setStatusMessage("레이어를 먼저 선택해야 편집 도구를 사용할 수 있습니다."); + return; + } + + if (!selectedLayer.allowedTools.includes(tool)) { + setStatusMessage(`${selectedLayer.layerName} 레이어에서는 ${tool} 도구를 사용할 수 없습니다.`); + return; + } + + setActiveTool(tool); + setPendingInteraction(null); + latestSelectionRef.current = null; + + if (tool === "ADD") { + const addGuideMessage = + selectedLayer.geometryType === "Point" + ? `${selectedLayer.layerName} 신규 위치를 지도에서 클릭하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}` + : selectedLayer.geometryType === "LineString" + ? `${selectedLayer.layerName} 선형을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}` + : `${selectedLayer.layerName} 영역을 지도에서 연속 클릭으로 입력한 뒤 더블클릭으로 마무리하세요.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`; + setStatusMessage(addGuideMessage); + return; + } + + const toolLabel = tool === "MOVE" + ? "이동" + : tool === "VERTEX_EDIT" + ? "꼭지점편집" + : "삭제"; + const vertexGuideMessage = + tool === "VERTEX_EDIT" + ? `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 실꼭지점은 주황색, 새 점 삽입용 midpoint는 흰색으로 표시됩니다.${isSnapEnabled ? " 스냅핑이 켜져 있으면 기존 관로 선분과 꼭지점에 맞춰집니다." : ""}` + : null; + setStatusMessage( + vertexGuideMessage ?? + `지도에서 ${selectedLayer.layerName} 대상 객체를 클릭하면 ${toolLabel} 변경분을 편집 세션에 추가합니다.${tool === "MOVE" && isSnapEnabled ? " 스냅핑이 켜져 있으면 이동 중 기존 관로 꼭지점/선분에 맞춰집니다." : ""}`, + ); + }, [appendChange, isSnapEnabled, selectedLayer]); + + const deactivateTool = useCallback(() => { + setActiveTool(null); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage("편집 도구 선택을 해제했습니다. 추가, 이동, 꼭지점편집, 삭제 모드가 모두 꺼졌습니다."); + }, []); + + const registerAddGeometry = useCallback((geometry: Record | null) => { + if (!selectedLayer || !geometry) { + setStatusMessage("추가 geometry를 확인하지 못했습니다."); + return null; + } + + changeSequenceRef.current += 1; + const clientTempId = `NEW-${changeSequenceRef.current}`; + const changeId = createChangeId(`ADD-${selectedLayer.layerId}`, changeSequenceRef.current); + const nextGeometry = normalizePipeGeometry(selectedLayer.layerId, geometry); + + appendChange({ + changeId, + editType: "ADD", + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId: clientTempId, + featureRef: undefined, + objectLabel: clientTempId, + beforeGeometry: null, + afterGeometry: nextGeometry, + basicInfo: + selectedLayer.layerId === "tb_ms_swage_ppl" + ? { + ...EMPTY_GIS_EDIT_PIPE_BASIC_INFO, + } + : null, + basicInfoSavedAt: null, + summary: `${selectedLayer.layerName} 신규 객체를 지도에서 직접 입력했습니다.`, + createdAt: new Date().toISOString(), + source: "MAP_DRAW", + }); + setStatusMessage( + `${selectedLayer.layerName} 신규 객체를 추가했습니다. 계속 추가하려면 지도에서 다음 형상을 입력하세요.`, + ); + return changeId; + }, [appendChange, selectedLayer]); + + // 편집 모드 중 지도에서 선택한 객체를 현재 activeTool 기준 변경분으로 변환한다. + const registerFeatureSelection = useCallback((featureData: any) => { + if (!isEditMode) return { handled: false }; + + const previewChangeId = String(featureData?.get?.("changeId") ?? "").trim(); + if (previewChangeId) { + const previewChange = changes.find((change) => change.changeId === previewChangeId); + if (!previewChange) return { handled: true }; + + if (previewChange.editType === "ADD" && activeTool === "DELETE") { + setLastSavedPayload(null); + setChanges((previous) => previous.filter((change) => change.changeId !== previewChangeId)); + setFocusedChangeId((current) => (current === previewChangeId ? null : current)); + setPendingInteraction((current) => (current?.changeId === previewChangeId ? null : current)); + latestSelectionRef.current = null; + setStatusMessage(`${previewChange.layerName} 신규 객체를 편집 세션에서 삭제했습니다.`); + return { + handled: true, + isPreviewSelection: true, + editType: "DELETE", + }; + } + + if ( + previewChange.editType !== "ADD" && + previewChange.editType !== "DELETE" && + activeTool === "DELETE" + ) { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === previewChangeId + ? { + ...change, + editType: "DELETE", + afterGeometry: null, + summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + } + : change, + ), + ); + setFocusedChangeId(previewChangeId); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage(`${previewChange.layerName} ${previewChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: "DELETE", + fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId, + }; + } + + if ( + previewChange.editType === "DELETE" && + (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") + ) { + setFocusedChangeId(previewChangeId); + setPendingInteraction(null); + setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요."); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: activeTool, + fid: previewChange.fid ?? previewChange.featureRef?.fid ?? previewChange.objectId, + }; + } + + if ( + previewChange.editType !== "DELETE" && + (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") + ) { + setFocusedChangeId(previewChangeId); + setPendingInteraction({ changeId: previewChangeId, editType: activeTool }); + const targetLabel = previewChange.editType === "ADD" ? "신규 객체" : previewChange.objectLabel; + setStatusMessage( + activeTool === "MOVE" + ? `${previewChange.layerName} ${targetLabel}를 드래그해 위치를 조정하세요.` + : `${previewChange.layerName} ${targetLabel}의 꼭지점을 드래그해 형상을 조정하세요.`, + ); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: activeTool, + }; + } + + focusChange(previewChangeId); + return { + handled: true, + isPreviewSelection: true, + changeId: previewChangeId, + editType: previewChange.editType, + }; + } + + if (!activeTool || !selectedLayer) return { handled: false }; + if (activeTool === "ADD") return { handled: true, isPreviewSelection: false }; + + const featureLayerId = getFeatureLayerId(featureData); + if (featureLayerId !== selectedLayer.layerId) { + setStatusMessage( + selectedLayer.layerId === "tb_ms_swage_ppl" + ? "하수관로를 선택해주세요." + : `현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`, + ); + return { handled: true }; + } + + const objectId = getFeatureObjectId(featureData); + if (!objectId) { + setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 편집 세션에 추가하지 못했습니다."); + return { handled: true }; + } + + const existingGeometryChange = findExistingGeometryChange(selectedLayer.layerId, objectId); + if (existingGeometryChange?.editType === "DELETE" && (activeTool === "MOVE" || activeTool === "VERTEX_EDIT")) { + focusChange(existingGeometryChange.changeId); + setPendingInteraction(null); + setStatusMessage("이미 삭제 예정인 관로입니다. 이동 또는 꼭지점편집이 필요하면 마지막 변경 취소 후 다시 선택하세요."); + return { + handled: true, + isPreviewSelection: false, + changeId: existingGeometryChange.changeId, + editType: "DELETE", + fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId, + }; + } + + if (existingGeometryChange && existingGeometryChange.editType !== "DELETE" && activeTool === "DELETE") { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === existingGeometryChange.changeId + ? { + ...change, + editType: "DELETE", + afterGeometry: null, + summary: `${change.layerName} ${change.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + } + : change, + ), + ); + setFocusedChangeId(existingGeometryChange.changeId); + setPendingInteraction(null); + latestSelectionRef.current = null; + setStatusMessage(`${existingGeometryChange.layerName} ${existingGeometryChange.objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId: existingGeometryChange.changeId, + editType: "DELETE", + fid: existingGeometryChange.fid ?? existingGeometryChange.featureRef?.fid ?? objectId, + }; + } + + const selectionSignature = `${activeTool}:${selectedLayer.layerId}:${objectId}`; + const now = Date.now(); + if ( + latestSelectionRef.current?.signature === selectionSignature && + now - latestSelectionRef.current.timestamp < 400 + ) { + return { handled: true }; + } + latestSelectionRef.current = { signature: selectionSignature, timestamp: now }; + + const existingChange = findExistingObjectChange(activeTool, selectedLayer.layerId, objectId); + if (existingChange) { + focusChange(existingChange.changeId); + if (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") { + setPendingInteraction({ changeId: existingChange.changeId, editType: activeTool }); + } + return { + handled: true, + isPreviewSelection: false, + changeId: existingChange.changeId, + editType: existingChange.editType, + fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId, + }; + } + + const geometryObject = toGeometryObject(featureData); + const geometryType = normalizeGeometryType(geometryObject?.type); + const featureRef = getFeatureRef(featureData); + const objectLabel = getFeatureObjectLabel(featureData) || objectId; + const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject); + const nextGeometry = + activeTool === "MOVE" + ? normalizedGeometryObject + : activeTool === "VERTEX_EDIT" + ? normalizedGeometryObject + : activeTool === "DELETE" + ? null + : normalizedGeometryObject; + const summaryMap: Record = { + MOVE: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 지도에서 드래그해 위치를 조정하세요.`, + VERTEX_EDIT: `${selectedLayer.layerName} ${objectLabel} 객체를 선택했습니다. 주황색 실꼭지점을 드래그하거나 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`, + DELETE: `${selectedLayer.layerName} ${objectLabel} 객체를 임시 삭제 대상으로 표시했습니다.`, + ADD: `${selectedLayer.layerName} ${objectLabel} 객체를 추가했습니다.`, + }; + changeSequenceRef.current += 1; + const changeId = createChangeId(`${activeTool}-${objectId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: activeTool, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId, + fid: featureRef?.fid ?? objectId, + featureRef, + objectLabel, + beforeGeometry: normalizedGeometryObject, + afterGeometry: nextGeometry, + featureProperties: getFeatureProperties(featureData), + basicInfo: null, + basicInfoSavedAt: null, + summary: + activeTool === "VERTEX_EDIT" && geometryType === "Point" + ? `${selectedLayer.layerName} ${objectLabel}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` + : summaryMap[activeTool], + createdAt: new Date().toISOString(), + source: "MAP_SELECTION", + }); + setStatusMessage(summaryMap[activeTool]); + setFocusedChangeId(changeId); + if (activeTool === "MOVE" || activeTool === "VERTEX_EDIT") { + if (activeTool === "VERTEX_EDIT" && geometryType === "Point") { + setPendingInteraction(null); + } else { + setPendingInteraction({ changeId, editType: activeTool }); + } + } else { + setPendingInteraction(null); + } + return { handled: true, isPreviewSelection: false, changeId, editType: activeTool, fid: featureRef?.fid ?? objectId }; + }, [activeTool, appendChange, findExistingGeometryChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); + + const registerPipeDetailSelection = useCallback((featureData: any) => { + if (!isEditMode || !selectedLayer) return { handled: false }; + if (selectedLayer.layerId !== "tb_ms_swage_ppl") return { handled: false }; + + const featureLayerId = getFeatureLayerId(featureData); + if (featureLayerId !== selectedLayer.layerId) { + setStatusMessage("하수관로를 선택해주세요."); + return { handled: true }; + } + + const objectId = getFeatureObjectId(featureData); + if (!objectId) { + setStatusMessage("선택 객체의 관리번호를 찾을 수 없어 상세정보 수정 화면을 열 수 없습니다."); + return { handled: true }; + } + + const existingChange = + changes.find( + (change) => + change.layerId === selectedLayer.layerId && + change.editType !== "DELETE" && + String(change.objectId ?? "") === objectId, + ) ?? null; + if (existingChange) { + focusChange(existingChange.changeId); + setPendingInteraction(null); + setStatusMessage(`${selectedLayer.layerName} ${objectId} 상세정보를 수정할 수 있습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId: existingChange.changeId, + editType: existingChange.editType as GisEditTool, + fid: existingChange.fid ?? existingChange.featureRef?.fid ?? objectId, + }; + } + + const geometryObject = toGeometryObject(featureData); + const normalizedGeometryObject = normalizePipeGeometry(selectedLayer.layerId, geometryObject); + const featureRef = getFeatureRef(featureData); + const objectLabel = getFeatureObjectLabel(featureData) || objectId; + changeSequenceRef.current += 1; + const changeId = createChangeId(`DETAIL-${objectId}`, changeSequenceRef.current); + + appendChange({ + changeId, + editType: "VERTEX_EDIT" as const, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + objectId, + fid: featureRef?.fid ?? objectId, + featureRef, + objectLabel, + beforeGeometry: normalizedGeometryObject, + afterGeometry: normalizedGeometryObject, + featureProperties: getFeatureProperties(featureData), + basicInfo: null, + basicInfoSavedAt: null, + attributeOnly: true, + summary: `${selectedLayer.layerName} ${objectLabel} 상세정보 수정 대상으로 선택했습니다.`, + createdAt: new Date().toISOString(), + source: "MAP_SELECTION", + }); + setFocusedChangeId(changeId); + setPendingInteraction(null); + setStatusMessage(`${selectedLayer.layerName} ${objectLabel} 상세정보를 수정할 수 있습니다.`); + return { + handled: true, + isPreviewSelection: false, + changeId, + editType: "VERTEX_EDIT", + fid: featureRef?.fid ?? objectId, + }; + }, [appendChange, changes, focusChange, isEditMode, selectedLayer]); + + const save = useCallback((map: any) => { + if (!selectedLayer || actionableChanges.length === 0) { + setStatusMessage("저장할 편집 변경분이 없습니다."); + return null; + } + + const payload: GisEditSavePayload = { + screenId: SCREEN_ID, + layerId: selectedLayer.layerId, + layerName: selectedLayer.layerName, + saveTargetType: "FINAL", + view: { + srs: FLOW_DIRECTION_SRS, + bbox: buildBbox(map), + }, + changes: actionableChanges.map((change) => ({ + changeId: change.changeId, + editType: change.editType, + objectId: change.editType === "ADD" ? undefined : change.objectId, + clientTempId: change.editType === "ADD" ? change.objectId : undefined, + fid: change.editType === "ADD" ? undefined : change.fid ?? change.featureRef?.fid ?? change.objectId, + baseEditSn: change.editType === "ADD" ? undefined : change.baseEditSn, + featureRef: change.featureRef, + attributes: buildPipeSaveAttributes(change.basicInfo), + beforeGeometry: change.beforeGeometry, + afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry, + geometry: change.editType === "ADD" ? change.afterGeometry : undefined, + })), + }; + + return payload; + }, [actionableChanges, selectedLayer]); + + const removeLastChange = useCallback(() => { + setLastSavedPayload(null); + setChanges((previous) => { + if (previous.length === 0) return previous; + const [removedChange, ...rest] = previous; + if (!removedChange) return previous; + if (focusedChangeId === removedChange.changeId) { + setFocusedChangeId(rest[0]?.changeId ?? null); + } + if (pendingInteraction?.changeId === removedChange.changeId) { + setPendingInteraction(null); + } + latestSelectionRef.current = null; + return rest; + }); + setStatusMessage("가장 최근 편집 변경분을 세션에서 제거했습니다."); + }, [focusedChangeId, pendingInteraction]); + + const removeFocusedChange = useCallback(() => { + const targetChangeId = focusedChangeId ?? pendingInteraction?.changeId ?? null; + if (!targetChangeId) { + setStatusMessage("취소할 편집 변경분을 선택해주세요."); + return; + } + + const removedChange = changes.find((change) => change.changeId === targetChangeId) ?? null; + if (!removedChange) { + setStatusMessage("선택한 편집 변경분을 찾을 수 없습니다."); + return; + } + + setLastSavedPayload(null); + setChanges((previous) => previous.filter((change) => change.changeId !== targetChangeId)); + + if (focusedChangeId === targetChangeId) { + const nextFocusedChange = changes.find((change) => change.changeId !== targetChangeId) ?? null; + setFocusedChangeId(nextFocusedChange?.changeId ?? null); + } + if (pendingInteraction?.changeId === targetChangeId) { + setPendingInteraction(null); + } + + latestSelectionRef.current = null; + setStatusMessage( + removedChange.attributeOnly + ? `${removedChange.layerName} ${removedChange.objectLabel} 상세정보 변경을 취소했습니다.` + : `${removedChange.layerName} ${removedChange.objectLabel} 편집 변경을 취소했습니다.`, + ); + }, [changes, focusedChangeId, pendingInteraction]); + + const clearChanges = useCallback(() => { + setLastSavedPayload(null); + setChanges([]); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + setIsSnapEnabled(false); + latestSelectionRef.current = null; + setStatusMessage("편집 세션을 초기화했습니다."); + }, []); + + const discardSession = useCallback(() => { + setIsEditMode(false); + setLastSavedPayload(null); + setChanges([]); + setActiveTool(null); + setPendingInteraction(null); + setFocusedChangeId(null); + setIsSnapEnabled(false); + latestSelectionRef.current = null; + setStatusMessage("하수관로 편집을 종료하고 세션 변경분을 초기화했습니다."); + }, []); + + const updateChangeGeometry = useCallback(( + changeId: string, + afterGeometry: Record | null, + summary: string, + options?: { keepVertexEditActive?: boolean; keepMoveToolActive?: boolean }, + ) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + afterGeometry, + summary, + } + : change, + ), + ); + setFocusedChangeId(changeId); + if (options?.keepVertexEditActive) { + setActiveTool("VERTEX_EDIT"); + setPendingInteraction({ changeId, editType: "VERTEX_EDIT" }); + } else if (options?.keepMoveToolActive) { + // MOVE는 버튼 재클릭 전까지 도구를 유지하고, 다음 지도 클릭에서 새 대상을 선택하게 한다. + setActiveTool("MOVE"); + setPendingInteraction(null); + } else { + setActiveTool(null); + setPendingInteraction(null); + } + latestSelectionRef.current = null; + setStatusMessage(summary); + }, []); + + const updateChangeBasicInfo = useCallback(( + changeId: string, + basicInfo: GisEditPipeBasicInfo, + ) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + featureRef: { + ...change.featureRef, + fcltMngNo: basicInfo.fcltMngNo || change.featureRef?.fcltMngNo, + mngInstCd: basicInfo.mngInstCd || change.featureRef?.mngInstCd, + }, + basicInfo, + basicInfoSavedAt: new Date().toISOString(), + } + : change, + ), + ); + setFocusedChangeId(changeId); + setStatusMessage("관로 기본정보를 편집 세션에 저장했습니다. 최종 GIS 저장 전까지는 DB에 반영되지 않습니다."); + }, []); + + const updateChangeBaseEditSn = useCallback((changeId: string, baseEditSn: string) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + baseEditSn, + } + : change, + ), + ); + }, []); + + const clearPendingInteraction = useCallback(() => { + setPendingInteraction(null); + }, []); + + const announceStatus = useCallback((message: string) => { + setStatusMessage(message); + }, []); + + return { + isEditMode, + editableLayers, + selectedLayer, + selectedLayerId, + activeTool, + isSnapEnabled, + changes, + statusMessage, + canUseTools, + canSave, + lastSavedPayload, + pendingInteraction, + focusedChangeId, + pendingApiMessage: + "현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, BE 저장 API(`/api/v1/map/gisEditSessions/save`) 최종본 저장까지 포함합니다.", + canUndo: actionableChanges.length > 0, + toggleEditMode, + toggleSnapEnabled, + selectLayer, + activateTool, + deactivateTool, + registerAddGeometry, + registerFeatureSelection, + registerPipeDetailSelection, + save, + removeFocusedChange, + removeLastChange, + clearChanges, + discardSession, + updateChangeGeometry, + updateChangeBasicInfo, + updateChangeBaseEditSn, + clearPendingInteraction, + focusChange, + announceStatus, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx new file mode 100644 index 000000000..fabd7c029 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -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(null); + + const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [uploadHistory, setUploadHistory] = useState( + INITIAL_UPLOAD_HISTORY, + ); + const [selectedUploadId, setSelectedUploadId] = useState( + 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, + ) => { + setSelectedFacilityTypeCode(event.target.value); + resetSelectedFile(); + }; + + const handleOpenFilePicker = () => { + if (!selectedFacility) { + window.alert("시설구분을 먼저 선택하세요."); + return; + } + + fileInputRef.current?.click(); + }; + + const handleFileSelected = (event: ChangeEvent) => { + 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 ( +
+
+
+

+ GIS 관리 +

+
+ +
+
+ GIS 업로드 +
+ +
+
+ + + +
+ +
+ + + +
+
+ + + +
+
+ GIS 업로드 현황 +
+ + +
+ +
+ + + + + no + + + 시설구분 + + + 파일명 + + + 등록일자 + + + 등록자 + + + + + + {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 ( + setSelectedUploadId(history.uploadId)} + style={{ backgroundColor }} + > + + {rowNumber} + + + {history.facilityTypeName} + + + {history.fileName} + + + {history.registeredAt} + + + {history.registeredBy} + + + ); + })} + +
+
+ + {uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx new file mode 100644 index 000000000..b49fad160 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -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(DEFAULT_FILTERS); + const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0"); + const [selectedRow, setSelectedRow] = useState(null); + const [rows, setRows] = useState([]); + 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, 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), + 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 ( +
+ +
+

침입수 분석결과

+
+ +
+
+
+
+ 상수사용기간 + { + setFilters((current) => ({ + ...current, + aplcnYmFrom: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> + ~ + { + setFilters((current) => ({ + ...current, + aplcnYmTo: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> +
+ +
+ { + 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} + /> +
+ +
+ + +
+
+
+ +
+ + 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} + /> +
+ + {selectedRow ? ( +
+ {"선택 분석: "} + {selectedRow.anlsInfo} +
+ ) : null} +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts b/apps/sn/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts new file mode 100644 index 000000000..5112afe49 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts @@ -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; + pipeGeometry: Record; + pipes: InftwtResultMapViewPipe[]; + pipeCount: number; + oilqPipeCount: number; +}; diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts b/apps/sn/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts new file mode 100644 index 000000000..11984c8e1 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts @@ -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; + 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(null); + const zoneLayerRef = useRef | null>(null); + const zoneSourceRef = useRef(null); + const pipeLayerRef = useRef | null>(null); + const pipeSourceRef = useRef(null); + const mapViewCacheRef = useRef>(new Map()); + const [activeMapView, setActiveMapView] = useState(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, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx index c958d5caa..eb436b1fb 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -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 ( -
+
-
-

관로현황{titleSuffix}

+
+

관로현황{titleSuffix}

-
-
+
+
); return ( -
-

침입수 분석

- {renderBody()} +
+ {isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx index ec1cec8b1..3b9d412d1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -1,21 +1,25 @@ -'use client' +"use client"; -import React, { useEffect, useMemo, useState } from 'react'; -import moment from 'moment'; +import React, { useEffect, useMemo, useState } from "react"; +import moment from "moment"; import { CHART_FORM, type ChartConfig, MainChart, -} from '@common/business_components'; -import { SelectBox } from '@common/business_components/ui'; -import { Button, Input, Label } from '@common/components'; +} from "@common/business_components"; +import { SelectBox } from "@common/business_components/ui"; +import { Button, Input, Label } from "@common/components"; -import type { TRdiiAnls } from './aplcnYms'; -import * as fn from './aplcnYms'; +import type { TRdiiAnls } from "./aplcnYms"; +import * as fn from "./aplcnYms"; type ModalOpenProp = { - pplMngNo?: string; // 관로관리번호 + pplMngNo?: string; + rainInfwtAnlsSn?: number; + aplcnYm?: string; + analysisInfo?: string; + resultOnly?: boolean; }; type LineBarChartData = { @@ -24,112 +28,77 @@ type LineBarChartData = { barVal0: number; }; -const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; +const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: "선택" }]; -const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { +const normalizePplMngNo = (value: string) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return ""; + return trimmed.startsWith("L") ? trimmed : `L${trimmed}`; +}; - const [aplcnYm, setAplcnYm] = useState(''); +const RdiiChart = ({ + pplMngNo = "", + rainInfwtAnlsSn = 0, + aplcnYm = "", + analysisInfo = "", + resultOnly = false, +}: ModalOpenProp) => { + const [selectedAplcnYm, setSelectedAplcnYm] = useState(""); const [rdiiAnlss, setRdiiAnlss] = useState([]); const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>( INITIAL_ANALYSIS_OPTIONS, ); - const [rdiiAnls, setRdiiAnls] = useState(null); // 강우유입수분석정보 - const [rainInfwtAnlsSn, setRainInfwtAnlsSn] = useState(0);// 강우유입수분석 정보 선택시 사용 + const [rdiiAnls, setRdiiAnls] = useState(null); + const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] = useState(0); const [chartData, setChartData] = useState([]); - const [rdiiQty, setRdiiQty] = useState('0'); // 강우유입수량 - const [rainTotal, setRainTotal] = useState('0'); // 적용강우량 - const [totalVolume, setTotalVolume] = useState('0');// 총체적 m³/mm + const [rdiiQty, setRdiiQty] = useState("0"); + const [rainTotal, setRainTotal] = useState("0"); + const [totalVolume, setTotalVolume] = useState("0"); - const aplcnYms = fn.useAplcnYms('rdiiAnlsRslt'); // 상수사용월 데이터 가져오기 - const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : ''; + const normalizedPplMngNo = normalizePplMngNo(pplMngNo); + const isResultOnlyMode = resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo); + const aplcnYms = fn.useAplcnYms(isResultOnlyMode ? "" : "rdiiAnlsRslt"); const chartForm = CHART_FORM.LINEBAR2; const chartConfig = { barVal0: { - label: '강수량', - color: '#33AAFF', + label: "강수량", + color: "#33AAFF", }, lineVal0: { - label: 'RDII', - color: '#FF0000', + label: "RDII", + color: "#FF0000", }, } as ChartConfig; const hasChartData = useMemo(() => chartData.length > 0, [chartData]); const resetState = () => { - setAplcnYm(''); // 상수사용월 초기화 - setRdiiAnlss([]); // 강우유입수분석 초기화 - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); // 강우유입수분석정보 초기화 - setRdiiAnls(null); // 강우유입수분석정보 초기화 - setRainInfwtAnlsSn(0); // 강우유입수분석 일련번호 초기화 - setChartData([]); - setRdiiQty('0'); // 강우유입수량 초기화 - setRainTotal('0');// 적용강우량 초기화 - setTotalVolume('0');// 총체적 m³/mm 초기화 - }; - - const handleAplcnYmChange = async (val: string | number) => { - const selectedValue = String(val); - - setAplcnYm(selectedValue); + setSelectedAplcnYm(""); setRdiiAnlss([]); + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); setRdiiAnls(null); - setRainInfwtAnlsSn(0); + setSelectedRainInfwtAnlsSn(0); setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - - if (!selectedValue || selectedValue === '0') { - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - return; - } - - try { - const data = await fn.selectRdiiAnls(selectedValue); // 상수사용월 변경시 분석 정보 리스트 가져오기 - const list = data ?? []; - setRdiiAnlss(list); - setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list)); - } catch (error) { - console.error('selectRdiiAnls 에러:', error); - setRdiiAnlss([]); - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - } + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); }; - const handleRdiiAnlsSnChange = (val: string | number) => { - const selectedValue = Number(val); - - setRainInfwtAnlsSn(selectedValue); - setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - - if (selectedValue === 0) { - setRdiiAnls(null); - return; - } - - const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null; - setRdiiAnls(selectedAnls); - }; - - const fetchFilteredData = async () => { - if (!rainInfwtAnlsSn || !normalizedPplMngNo) { + const loadResultChart = async (targetRainInfwtAnlsSn: number) => { + if (!targetRainInfwtAnlsSn || !normalizedPplMngNo) { setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); return; } try { const [rsltData, rainData, qtyData] = await Promise.all([ - fn.selectRdiiAnlsRslt(rainInfwtAnlsSn, normalizedPplMngNo), - fn.selectRainData(rainInfwtAnlsSn), - fn.selectPplRdiiQty(rainInfwtAnlsSn, normalizedPplMngNo), + fn.selectRdiiAnlsRslt(targetRainInfwtAnlsSn, normalizedPplMngNo), + fn.selectRainData(targetRainInfwtAnlsSn), + fn.selectPplRdiiQty(targetRainInfwtAnlsSn, normalizedPplMngNo), ]); const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []); @@ -143,7 +112,7 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { } }); - setChartData(rsltDataFormatted);// 차트 데이터 설정 + setChartData(rsltDataFormatted); const avgRdii = Number(qtyData?.rdiih ?? 0); const appliedRainfall = Number(qtyData?.raintotal ?? 0); @@ -152,18 +121,80 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { ? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal) : 0; - setRdiiQty(Math.round(avgRdii * 24).toLocaleString('ko-KR')); - setRainTotal(appliedRainfall.toLocaleString('ko-KR')); - setTotalVolume(rdiiPerRainfall.toLocaleString('ko-KR')); + setRdiiQty(Math.round(avgRdii * 24).toLocaleString("ko-KR")); + setRainTotal(appliedRainfall.toLocaleString("ko-KR")); + setTotalVolume(rdiiPerRainfall.toLocaleString("ko-KR")); } catch (error) { - console.error('rdiiRsltChart 에러:', error); + console.error("RDII 분석결과 차트 조회 중 오류:", error); setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); } }; + useEffect(() => { + if (!isResultOnlyMode) return; + + setSelectedAplcnYm(aplcnYm); + setSelectedRainInfwtAnlsSn(rainInfwtAnlsSn); + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + void loadResultChart(rainInfwtAnlsSn); + }, [aplcnYm, isResultOnlyMode, normalizedPplMngNo, rainInfwtAnlsSn]); + + const handleAplcnYmChange = async (val: string | number) => { + const selectedValue = String(val); + + setSelectedAplcnYm(selectedValue); + setRdiiAnlss([]); + setRdiiAnls(null); + setSelectedRainInfwtAnlsSn(0); + setChartData([]); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); + + if (!selectedValue || selectedValue === "0") { + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + return; + } + + const data = await fn.selectRdiiAnls(selectedValue); + const list = data ?? []; + setRdiiAnlss(list); + setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list)); + }; + + const handleRdiiAnlsSnChange = (val: string | number) => { + const selectedValue = Number(val); + + setSelectedRainInfwtAnlsSn(selectedValue); + setChartData([]); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); + + if (selectedValue === 0) { + setRdiiAnls(null); + return; + } + + const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null; + setRdiiAnls(selectedAnls); + }; + + const fetchFilteredData = async () => { + if (!selectedRainInfwtAnlsSn || !normalizedPplMngNo) { + setChartData([]); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); + return; + } + + await loadResultChart(selectedRainInfwtAnlsSn); + }; + const renderInfoInput = (label: string, value?: string | number | null) => (
); - const renderBody = () => ( -
-
-
+ const renderResultPanel = () => ( +
+

RDII 분석 결과

+
+
+
+ 상수사용 적용연월 +
+
+ {selectedAplcnYm ? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM") : "-"} +
+
+ +
+
+ 강우유입수량 분석결과 +
+
+
+
평균RDII
+
{rdiiQty}
+
m³/d
+
+
+
적용 강우량
+
{rainTotal}
+
mm
+
+
+
강우량 대비 RDII
+
{totalVolume}
+
m³/mm
+
+
+
+
+ {hasChartData ? ( + + ) : ( +
데이터가 없습니다.
+ )} +
+
+ ); + + const renderDefaultBody = () => ( +
-

- 강우유입수분석 선택 조건 -

+

강우유입수량 선택 조건

- +
- +
- {renderInfoInput('오수전환율:', rdiiAnls?.drtwSwtcRt ?? '')} - {renderInfoInput('수용가-맨홀 매칭:', rdiiAnls?.atflMpngNm ?? '')} - {renderInfoInput('관망도 파일명:', rdiiAnls?.atflInpNm ?? '')} - {renderInfoInput('강우 파일명:', rdiiAnls?.atflRainNm ?? '')} - {renderInfoInput('RTK계수 출처:', rdiiAnls?.srcNm ?? '')} + {renderInfoInput("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")} + {renderInfoInput("수용가-맨홀 매칭:", rdiiAnls?.atflMpngNm ?? "")} + {renderInfoInput("관망도 파일명", rdiiAnls?.atflInpNm ?? "")} + {renderInfoInput("강우 파일명", rdiiAnls?.atflRainNm ?? "")} + {renderInfoInput("RTK계수 출처:", rdiiAnls?.srcNm ?? "")}
- +    - +
-
-

- 강우유입수분석 결과 -

- -
- {/* 좌측 카드 */} -
-
- 상수사용량 적용연월 -
-
- {aplcnYm ? moment(aplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} -
-
- - {/* 우측 카드 */} -
- {/* 좌측: 제목 (세로 가운데 정렬) */} -
-
- 강우유입수량 분석결과 -
-
- - {/* 우측: 지표 영역 */} -
-
-
- 평균RDII -
-
- {rdiiQty} -
-
- m³/d -
-
- -
-
- 적용 강우량 -
-
- {rainTotal} -
-
- mm -
-
- -
-
- 강우량 대비 RDII -
-
- {totalVolume} -
-
- m³/mm -
-
-
-
-
- -
- {hasChartData ? ( - - ) : ( -
- 데이터가 없습니다. -
- )} -
-
+ {renderResultPanel()}
); + if (isResultOnlyMode) { + return
{renderResultPanel()}
; + } + return (

RDII 분석

- {renderBody()} + {renderDefaultBody()}
); }; -export default React.memo(RdiiChart); +export default React.memo(RdiiChart); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 36cbb46f6..202f4e338 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -61,6 +61,11 @@ const linkedFacilityHighlightStyle = new Style({ zIndex: 9999, }); +type MngInstOption = { + mngInstCd: string; + lwrInstNm: string; +}; + const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => { const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; }; const [layerNm, setLayerNm] = useState(""); // 레이어 이름 상태 @@ -80,6 +85,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const [chmbrYdnts, setChmbrYdnts] = useState(''); // 로딩 상태 const linkedFacilityFeatureRef = useRef(null); const linkedFacilityOriginalStyleRef = useRef(null); + const [mngInstOptions, setMngInstOptions] = useState([]); // 🎯 관로관리번호 추출 const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || ""; @@ -87,6 +93,10 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, const featureId = typeof featureData.getId === 'function' ? featureData.getId() : featureData.fid; + const selectedLayerTypeName = featureData?.values_ + ? featureData?.id_?.split('.')?.[0] + : featureData?.lyrTypeNm; + const isPipeFeature = selectedLayerTypeName === 'tb_ms_swage_ppl'; // 🎯 데이터 전처리 메모이제이션 const processedData = useMemo(() => { @@ -98,6 +108,90 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }; }, [featureData?.values_]); + useEffect(() => { + let isMounted = true; + + if (isPipeFeature) { + setMngInstOptions([]); + return () => { + isMounted = false; + }; + } + + const fetchMngInstOptions = async () => { + try { + const result = await getData("v1/fcltppl/mngInsts"); + if (!isMounted) return; + const sortedOptions = Array.isArray(result) + ? [...result].sort((a, b) => + String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare( + String(b?.lwrInstNm ?? b?.mngInstCd ?? ""), + "ko", + ), + ) + : []; + setMngInstOptions(sortedOptions); + } catch (error) { + console.error("관리기관 목록 조회 실패:", error); + if (isMounted) setMngInstOptions([]); + } + }; + + fetchMngInstOptions(); + return () => { + isMounted = false; + }; + }, [isPipeFeature]); + + const mngInstNameMap = useMemo(() => { + const map = new Map(); + mngInstOptions.forEach((option) => { + const code = String(option.mngInstCd ?? "").trim(); + if (!code) return; + map.set(code, option.lwrInstNm || code); + }); + return map; + }, [mngInstOptions]); + + const withMngInstName = (data: any) => { + if (!data || typeof data !== "object") return data; + const mngInstCd = String(data.mng_inst_cd ?? data.mngInstCd ?? "").trim(); + if (!mngInstCd) return data; + return { + ...data, + mngInstNm: mngInstNameMap.get(mngInstCd) ?? data.mngInstNm ?? data.mng_inst_nm ?? mngInstCd, + }; + }; + + const hasDetailValue = (value: unknown) => { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim() !== ""; + return true; + }; + + const isValidDetailIdentifier = (value: unknown) => { + if (typeof value !== "string") return false; + const normalizedValue = value.trim(); + if (!normalizedValue) return false; + if (normalizedValue.toLowerCase() === "null") return false; + if (normalizedValue.toLowerCase() === "undefined") return false; + return true; + }; + + const mergeDetailData = (baseData: any, detailData: any) => { + if (!detailData || typeof detailData !== "object") return baseData; + + const meaningfulDetailData = Object.entries(detailData).reduce>((acc, [key, value]) => { + if (hasDetailValue(value)) acc[key] = value; + return acc; + }, {}); + + return { + ...(baseData ?? {}), + ...meaningfulDetailData, + }; + }; + const clearLinkedFacilityHighlight = () => { if (linkedFacilityFeatureRef.current) { linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined); @@ -334,7 +428,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, } // 🎯 기본 데이터 포맷팅 - const formattedData = formatDisplayData(data); + const formattedData = withMngInstName(formatDisplayData(data)); if (selectedLayerName === '하수관로') { const installYmd = data.instl_ymd ?? data.instlYmd; formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd); @@ -352,10 +446,14 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, setButtonChk(true) // ✅ codeMapper 사용하여 API 결과도 변환 - setTableData(codeMapper({ - layerName: selectedLayerName, - data: { ...formattedData } - })); + { + const initialPipeData = withMngInstName(codeMapper({ + layerName: selectedLayerName, + data: { ...formattedData } + })); + setFetchData(initialPipeData); + setTableData(initialPipeData); + } break; case '하수맨홀': setButtonChk(true) @@ -409,28 +507,32 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, // 자체입력정보 API 엔드포인트 선택 let apiEndpoint = ''; - const fcltMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || null; - const mngInstCd = featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || null; + const fcltMngNo = String(featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "").trim(); + const mngInstCd = String(featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || "").trim(); + const canRequestDetail = isValidDetailIdentifier(fcltMngNo) && isValidDetailIdentifier(mngInstCd); if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`; else if (selectedLayerName === '하수맨홀') apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`; // API에서 상세 데이터 가져오기 - if (apiEndpoint) { + if (apiEndpoint && canRequestDetail) { try { const result = await getData(apiEndpoint); if (result) { - const processedApiData = codeMapper({ + const mergedData = mergeDetailData(formattedData, result); + const processedApiData = withMngInstName(codeMapper({ layerName: selectedLayerName, - data: { ...result, ...tableData } - }); + data: mergedData + })); setFetchData(processedApiData); + setTableData(processedApiData); } } catch (apiError) { - console.error('API 데이터 로딩 중 오류:', apiError); + void apiError; + console.warn("Pipe detail fallback kept initial feature data."); } } } catch (error) { @@ -439,7 +541,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, }; fetchData(); - }, [featureId, processedData]); + }, [featureId, processedData, mngInstNameMap]); useEffect(() => { const layerTypeNm = featureData?.values_ diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx new file mode 100644 index 000000000..4f0ff5e15 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -0,0 +1,347 @@ +"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 { + RDII_RESULT_ROW_SELECT_MESSAGE, + RDII_RESULT_ROW_SELECT_STORAGE_KEY, + type RdiiResultRow, +} from "./rdiiResultTypes"; + +const RDII_RESULT_PAGE_SIZE = 10; + +const RDII_RESULT_HEADLIST = [ + { id: "rowNumber", title: "일련번호" }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + { id: "swtrmtFcltNm", title: "하수처리시설" }, + { id: "aplcnYm", title: "상수사용연월" }, + { id: "drtwSwtcRt", title: "오수 전환율" }, + { id: "srcNm", title: "RTK 계수 출처" }, + { id: "cntbArea", title: "RDII 기여면적" }, + { id: "anlsInfo", title: "RDII\n분석정보" }, + { id: "regDt", title: "분석일자" }, + { id: "rgtrNm", title: "분석자" }, + { id: "scsYn", title: "분석성공여부" }, +] as const; + +type RdiiResultFilters = { + 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: RdiiResultFilters = { + ctpvCd: "0", + sggCd: "0", + sggName: "", + swtrmtFcltNm: "", + aplcnYmFrom: `${new Date().getFullYear()}01`, + aplcnYmTo: createDefaultAplcnYm(), +}; + +const postRowSelectMessage = (row: RdiiResultRow) => { + if (typeof window !== "undefined") { + try { + window.localStorage.setItem( + RDII_RESULT_ROW_SELECT_STORAGE_KEY, + JSON.stringify({ + row, + timestamp: Date.now(), + }), + ); + } catch (error) { + console.warn("RDII 분석결과 선택 동기화 저장에 실패했습니다.", error); + } + } + + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage( + { + type: RDII_RESULT_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); +}; + +export default function RdiiResultPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0"); + const [selectedRow, setSelectedRow] = useState(null); + const [rows, setRows] = useState([]); + 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: RdiiResultFilters) => { + const queryParams = new URLSearchParams({ + page: String(page - 1), + size: String(RDII_RESULT_PAGE_SIZE), + sort: "rainInfwtAnlsSn,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/rdiiAnls?${queryParams.toString()}`); + const content = Array.isArray(response?.content) ? response.content : []; + + const nextRows = content + .map((item: Record, 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), + rowNumber: (page - 1) * RDII_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 RdiiResultRow; + }) + .filter((item: RdiiResultRow) => 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("RDII 분석결과 목록 조회 중 오류:", error); + alert("RDII 분석결과 목록을 불러오지 못했습니다."); + 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?.rainInfwtAnlsSn) { + alert("다운로드할 분석결과를 먼저 선택해주세요."); + return; + } + + try { + setIsLoading(true); + const response = await getData(`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`); + const excelRows = Array.isArray(response?.content) ? response.content : []; + + await downloadExcelWithKoreanHeaders({ + data: excelRows as JSON[], + fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`, + sheetNameArray: ["RDII 분석결과"], + headerRange: "A1:D1", + headerMapping: { + rainInfwtAnlsSn: "RDII분석\n일련번호", + pplMngNo: "관로\n관리번호", + anlsTm: "분석시각", + predcOilq: "예측유량\n(m³/h)", + }, + }); + } catch (error) { + console.error("RDII 분석결과 다운로드 중 오류:", error); + alert("RDII 분석결과 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+

RDII 분석결과

+
+ +
+
+
+
+ 상수사용기간 + { + setFilters((current) => ({ + ...current, + aplcnYmFrom: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> + ~ + { + setFilters((current) => ({ + ...current, + aplcnYmTo: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> +
+ +
+ { + 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} + /> +
+ +
+ + +
+
+
+ +
+ + headName={<>{`분석이력 총 ${total.toLocaleString()}건`}} + tableData={rows} + headlist={RDII_RESULT_HEADLIST as unknown as { id: string; title: string }[]} + pageSize={RDII_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} + /> +
+ + {selectedRow ? ( +
+ {"선택 분석: "} + {selectedRow.anlsInfo} +
+ ) : null} +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts b/apps/sn/app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts new file mode 100644 index 000000000..7dc76db34 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts @@ -0,0 +1,47 @@ +"use client"; + +export const RDII_RESULT_ROW_SELECT_MESSAGE = "SN_RDII_RESULT_ROW_SELECT"; +export const RDII_RESULT_OVERLAY_TYPE = "RDII_RESULT"; +export const RDII_RESULT_ROW_SELECT_STORAGE_KEY = "SN_RDII_RESULT_ROW_SELECT_STORAGE"; + +export type RdiiResultRow = { + rowNumber: number; + rainInfwtAnlsSn: number; + ctpvCd?: string; + ctpvNm: string; + sggCd?: string; + sggNm: string; + swtrmtFcltNm: string; + aplcnYm: string; + drtwSwtcRt?: number | string; + srcNm?: string; + cntbArea?: number | string; + anlsInfo: string; + regDt?: string; + rgtrNm?: string; + scsYn?: string; +}; + +export type RdiiResultMapViewPipe = { + pplMngNo: string; + fcltMngNo: string; +}; + +export type RdiiResultMapViewResponse = { + analysis: { + rainInfwtAnlsSn: number; + aplcnYm: string; + ctpvCd?: string; + sggCd?: string; + swtrmtFcltNm?: string; + anlsInfo?: string; + }; + focusTargetType: "PIPE_EXTENT"; + focusTargetName: string; + focusLayerType?: "tb_ms_swage_ppl"; + focusFid?: string; + focusFallbackApplied?: boolean; + pipeGeometry: Record; + pipes: RdiiResultMapViewPipe[]; + pipeCount: number; +}; \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts b/apps/sn/app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts new file mode 100644 index 000000000..91b7efcdc --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts @@ -0,0 +1,366 @@ +"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 Style from "ol/style/Style.js"; + +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 { + RDII_RESULT_OVERLAY_TYPE, + RDII_RESULT_ROW_SELECT_MESSAGE, + RDII_RESULT_ROW_SELECT_STORAGE_KEY, + type RdiiResultMapViewResponse, + type RdiiResultRow, +} from "./rdiiResultTypes"; + +type UseRdiiResultMapParams = { + gisMaps: MutableRefObject; + setFeatureData: (value: any) => void; + setIsOpen: (value: boolean) => void; + setIsSearchModalOpen: (value: boolean) => void; +}; + +const RDII_PIPE_LAYER_ID = "SN_RDII_RESULT_PIPE_LAYER"; +const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120]; +const PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH"; + +const pipeStyle = [ + new Style({ + stroke: new Stroke({ + color: "rgba(15, 23, 42, 0.56)", + width: 9, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 20020, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(255, 255, 255, 0.98)", + width: 5, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 20021, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(59, 130, 246, 0.98)", + width: 2.5, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 20022, + }), +]; + +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?.("rainInfwtAnlsSn") ?? + data?.values_?.rainInfwtAnlsSn ?? + data?.rainInfwtAnlsSn ?? + 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(); + +export const useRdiiResultMap = ({ + gisMaps, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, +}: UseRdiiResultMapParams) => { + const activeResultRowRef = useRef(null); + const pipeLayerRef = useRef | null>(null); + const pipeSourceRef = useRef(null); + const mapViewCacheRef = useRef>(new Map()); + const [activeMapView, setActiveMapView] = useState(null); + + const clearLayers = useCallback(() => { + pipeSourceRef.current?.clear?.(); + pipeLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, [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: 20019, + }); + + layer.set("id", RDII_PIPE_LAYER_ID); + layer.set("lyrNm", "RDII 결과관로"); + 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(() => { + const map = gisMaps.current; + const pipeExtent = pipeSourceRef.current?.getExtent?.(); + if (!map || !pipeExtent || pipeExtent.some((value: number) => !Number.isFinite(value))) { + return; + } + + map.getView?.().fit?.(pipeExtent, { + duration: 350, + padding: DEFAULT_RESULT_FIT_PADDING, + maxZoom: 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: RdiiResultMapViewResponse) => { + const activeRow = activeResultRowRef.current; + const movedByRegion = moveByRegionCodes( + activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd, + activeRow?.sggCd ?? mapView.analysis?.sggCd, + ); + if (movedByRegion) return; + + fitToRenderedFeatures(); + }, + [fitToRenderedFeatures, moveByRegionCodes], + ); + + const renderMapView = useCallback( + async (mapView: RdiiResultMapViewResponse) => { + const pipeSource = ensurePipeLayer(); + if (!pipeSource) return; + + clearLayers(); + + const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry); + const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), { + dataProjection: pipeDataProjection, + featureProjection: GisApp.TO_PROJ, + }); + pipeFeatures.forEach((feature: any) => { + feature.setStyle(pipeStyle); + }); + pipeSource.addFeatures(pipeFeatures); + pipeSource.changed?.(); + pipeLayerRef.current?.setVisible?.(true); + pipeLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + + await moveToStableFocus(mapView); + }, + [clearLayers, ensurePipeLayer, gisMaps, moveToStableFocus], + ); + + const handleRowSelect = useCallback( + async (row: RdiiResultRow) => { + try { + activeResultRowRef.current = row; + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(PROC_SEARCH_EVENT, { + detail: { + ctpvCd: row.ctpvCd ?? "0", + sggCd: row.sggCd ?? "0", + swtrmtFcltNm: row.swtrmtFcltNm ?? "", + }, + }), + ); + } + + const cachedMapView = mapViewCacheRef.current.get(row.rainInfwtAnlsSn); + if (cachedMapView) { + setActiveMapView(cachedMapView); + void renderMapView(cachedMapView); + return; + } + + const payload = await getRawJson(`/sewerage-service/api/v1/rdiiAnlsRslt/mapView/${row.rainInfwtAnlsSn}`); + const mapView = (payload?.data ?? payload) as RdiiResultMapViewResponse; + mapViewCacheRef.current.set(row.rainInfwtAnlsSn, mapView); + setActiveMapView(mapView); + void renderMapView(mapView); + } catch (error) { + console.error("RDII 분석결과 지도 연계 중 오류:", error); + alert("RDII 분석결과를 지도에 연계하지 못했습니다."); + } + }, + [renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen], + ); + + useEffect(() => { + const handlePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== RDII_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return; + void handleRowSelect(event.data.row as RdiiResultRow); + }; + + const handleStorageEvent = (event: StorageEvent) => { + if (event.key !== RDII_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return; + + try { + const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow }; + if (!payload?.row) return; + void handleRowSelect(payload.row); + } catch (error) { + console.warn("RDII 분석결과 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) !== RDII_RESULT_OVERLAY_TYPE) { + return false; + } + + const overlayAnalysisSn = getOverlayAnalysisSn(data); + const basePplMngNo = getOverlayBasePplMngNo(data); + const analysisPplMngNo = getOverlayAnalysisPplMngNo(data); + const currentRow = activeResultRowRef.current; + const popupWindow = openWindowPopup("rdiiChart", { + pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""), + rainInfwtAnlsSn: overlayAnalysisSn || currentRow?.rainInfwtAnlsSn || 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, + }; +}; \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx new file mode 100644 index 000000000..a660d0c7e --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@common/components"; +import { VerticalTable } from "@/components/ui/verticalTable"; +import { getCmsData } from "@/components/lib/api"; +import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkholeTypes"; + +type Props = { + detail: SinkholeDetail; + nearbyPipes: SinkholeNearbyPipe[]; + radiusMeters: number; + onClose: () => void; + onSelectPipe: (pipe: SinkholeNearbyPipe) => void; +}; + +type PipeUseCodeItem = { + lwpoCd: string; + lwpoCdNm: string; +}; + +let pipeUseCodeCache: PipeUseCodeItem[] | null = null; +let pipeUseCodePromise: Promise | null = null; + +const display = (value: unknown) => { + const normalized = String(value ?? "").trim(); + return normalized ? normalized : "-"; +}; + +const displayNumber = (value: unknown, suffix = "") => { + if (value === null || value === undefined || value === "") return "-"; + const normalized = Number(value); + if (Number.isNaN(normalized)) return display(value); + return `${normalized.toLocaleString()}${suffix}`; +}; + +const parseElapsedYears = (value: unknown) => { + if (value === null || value === undefined || value === "") return null; + const normalized = Number(value); + return Number.isFinite(normalized) ? normalized : null; +}; + +const displayElapsedYears = (value: unknown) => displayNumber(value, "년"); + +const normalizePipeUseCodes = (data: any): PipeUseCodeItem[] => { + if (!Array.isArray(data)) return []; + return data.map((item: any) => ({ + lwpoCd: String(item?.lwpoCd ?? "").trim(), + lwpoCdNm: String(item?.lwpoCdNm ?? "").trim(), + })); +}; + +const loadPipeUseCodes = async () => { + if (pipeUseCodeCache) return pipeUseCodeCache; + if (!pipeUseCodePromise) { + pipeUseCodePromise = getCmsData(`/api/v1/code/cache/OGC011/Y`) + .catch(() => getCmsData(`/api/v1/code/cache/OGC011`)) + .then((data) => { + const codes = normalizePipeUseCodes(data); + pipeUseCodeCache = codes; + return codes; + }) + .finally(() => { + pipeUseCodePromise = null; + }); + } + return pipeUseCodePromise; +}; + +export default function SinkholeDetailPanel({ + detail, + nearbyPipes, + radiusMeters, + onClose, + onSelectPipe, +}: Props) { + const [pipeUseCodeMap, setPipeUseCodeMap] = useState>(new Map()); + const canShowNearby = String(detail.coordStatus ?? "").trim() === "SUCCESS"; + + useEffect(() => { + let cancelled = false; + + loadPipeUseCodes() + .then((codes) => { + if (cancelled) return; + setPipeUseCodeMap(new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm]))); + }) + .catch((error) => { + console.error("Failed to load OGC011 pipe use codes:", error); + }); + + return () => { + cancelled = true; + }; + }, []); + + const getPipeUseName = (pipe: SinkholeNearbyPipe) => { + const rawCode = String(pipe.pipeTypeCd ?? pipe.pipeTypeName ?? "").trim(); + if (!rawCode) return "-"; + return pipeUseCodeMap.get(rawCode) ?? rawCode; + }; + + const nearbyPipeElapsedSummary = useMemo(() => { + const elapsedYears = nearbyPipes + .map((pipe) => parseElapsedYears(pipe.elapsedYears)) + .filter((value): value is number => value !== null); + + if (elapsedYears.length === 0) { + return { average: null, oldest: null }; + } + + const total = elapsedYears.reduce((sum, value) => sum + value, 0); + const average = Math.round((total / elapsedYears.length) * 10) / 10; + const oldest = Math.max(...elapsedYears); + + return { average, oldest }; + }, [nearbyPipes]); + + const summaryTableData = useMemo( + () => [ + { + acdntNo: display(detail.acdntNo), + ocrnYmd: display(detail.ocrnYmd), + fullAddress: display(detail.fullAddress || detail.daddr), + sizeWidth: display(detail.sizeWidth), + sizeLength: display(detail.sizeLength), + sizeDepth: display(detail.sizeDepth), + detailCause: display(detail.detailCause), + restoreStatus: display(detail.restoreStatus), + restoreCompletedYmd: display(detail.restoreCompletedYmd), + }, + ], + [detail], + ); + + const summaryHeadList = [ + { id: "acdntNo", title: "사고번호" }, + { id: "ocrnYmd", title: "발생일자" }, + { id: "fullAddress", title: "주소" }, + { id: "sizeWidth", title: "발생규모폭" }, + { id: "sizeLength", title: "발생규모연장" }, + { id: "sizeDepth", title: "발생규모깊이" }, + { id: "detailCause", title: "상세발생원인" }, + { id: "restoreStatus", title: "복구상태" }, + { id: "restoreCompletedYmd", title: "복구완료일자" }, + ]; + + return ( + + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx new file mode 100644 index 000000000..b2935032a --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { SelectBox } from "@common/business_components/ui"; +import { + normalizeSinkholeYearRange, + SINKHOLE_MIN_SEARCH_YEAR, + validateSinkholeYearRange, + type SinkholeSyncSummary, + type SinkholeYearRange, +} from "./sinkholeTypes"; + +type MaybePromise = T | Promise; + +type Props = { + radiusMeters: number; + onApplyRadius: (radiusMeters: number) => MaybePromise; + yearRange: SinkholeYearRange; + onApplyYearRange: (yearRange: SinkholeYearRange) => MaybePromise; + lastSyncSummary?: SinkholeSyncSummary | null; + isAdmin?: boolean; + compact?: boolean; +}; + +export default function SinkholeLayerSettingPanel({ + radiusMeters, + onApplyRadius, + yearRange, + onApplyYearRange, + lastSyncSummary, + isAdmin = false, + compact = false, +}: Props) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [radiusText, setRadiusText] = useState(String(radiusMeters)); + const [yearRangeDraft, setYearRangeDraft] = useState(() => + normalizeSinkholeYearRange(yearRange), + ); + + useEffect(() => { + setRadiusText(String(radiusMeters)); + }, [radiusMeters]); + + useEffect(() => { + setYearRangeDraft(normalizeSinkholeYearRange(yearRange)); + }, [yearRange]); + + const commitRadius = async () => { + const nextValue = Number(radiusText.replace(/[^0-9]/g, "")); + if (!Number.isFinite(nextValue) || nextValue <= 0) { + alert("반경은 1m 이상 입력해야 합니다."); + return; + } + + try { + await onApplyRadius(nextValue); + alert(`지반침하반경은 ${nextValue}m로 반영되었습니다.`); + } catch { + // 적용 함수 내부에서 실패 사유를 안내한다. + } + }; + + const commitYearRange = async () => { + const normalized = normalizeSinkholeYearRange(yearRangeDraft, yearRange); + const validationMessage = validateSinkholeYearRange(normalized); + if (validationMessage) { + alert(validationMessage); + return; + } + + try { + await onApplyYearRange(normalized); + alert(`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`); + } catch { + alert("반영 중 오류가 발생했습니다."); + } + }; + + const updateYearRangeDraft = (key: keyof SinkholeYearRange, value: string) => { + setYearRangeDraft((current) => ({ + ...current, + [key]: String(value).replace(/[^0-9]/g, "").slice(0, 4), + })); + }; + + const currentYear = new Date().getFullYear(); + const yearSelectClass = "h-[22px] w-[62px] rounded-none border border-[#ccd6df] bg-white px-[4px] text-center text-[1rem] leading-none text-[#222]"; + const startYearOptions = Array.from( + { length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 }, + (_, index) => String(currentYear - index), + ) + .filter((year) => !yearRangeDraft.endYear || Number(year) <= Number(yearRangeDraft.endYear)) + .map((year) => ({ val: year, text: year })); + const endYearOptions = Array.from( + { length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 }, + (_, index) => String(currentYear - index), + ) + .filter((year) => !yearRangeDraft.startYear || Number(year) >= Number(yearRangeDraft.startYear)) + .map((year) => ({ val: year, text: year })); + + 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 = ; + + const settingRows = ( +
+
+
+ {indicator} + +
+
+ event.currentTarget.select()} + onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))} + onKeyDown={(event) => { + if (event.key === "Enter") void commitRadius(); + }} + /> + +
+
+
+
+ {indicator} + +
+
+
+ updateYearRangeDraft("startYear", String(value))} + selectContentClass="z-[101]" + labelClass="hidden" + /> +
+ ~ +
+ updateYearRangeDraft("endYear", String(value))} + selectContentClass="z-[101]" + labelClass="hidden" + /> +
+ +
+
+
+ ); + + if (compact) { + return ( +
+
{settingRows}
+
+ ); + } + + return ( +
+
+ + 지반침하정보 +
+ + {!isCollapsed && ( +
+
{settingRows}
+ + {isAdmin && lastSyncSummary ? ( +
+
최근 동기화
+
완료시각: {lastSyncSummary.finishedAt || "-"}
+
+ 성공 {lastSyncSummary.successCount}건 / 실패 {lastSyncSummary.failedCount}건 +
+
+ ) : null} +
+ )} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx new file mode 100644 index 000000000..e1f41b952 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button, Input } from "@common/components"; +import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { getRawJson } from "@/components/lib/api"; +import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import type { SelectDataType } from "@common/business_components"; +import { + buildSinkholeDefaultFilters, + buildSinkholeListQueryString, + formatDateInputToYmd, + formatYmdToDateInput, + normalizeSinkholeFilters, + SINKHOLE_COORD_STATUS_OPTIONS, + SINKHOLE_DOWNLOAD_EXCEL_HEADERS, + SINKHOLE_FILTER_SYNC_MESSAGE, + SINKHOLE_MIN_SEARCH_YMD, + SINKHOLE_RESTORE_STATUS_OPTIONS, + SINKHOLE_ROW_SELECT_MESSAGE, + type SinkholeFilters, + type SinkholeListItem, +} from "./sinkholeTypes"; + +const PAGE_SIZE = 20; +const DOWNLOAD_BATCH_SIZE = 2000; + +type SinkholeTableRow = SinkholeListItem & { + rowNo: number; + sizeWidthDisplay: string; + sizeLengthDisplay: string; + sizeDepthDisplay: string; + detailCauseDisplay: string; + restoreCompletedYmdDisplay: string; + coordStatusDisplay: string; +}; + +const HEADLIST = [ + { id: "acdntNo", title: "사고번호", widthPercent: 17 }, + { id: "ocrnYmd", title: "발생일자", widthPercent: 10 }, + { id: "fullAddress", title: "주소", widthPercent: 20 }, + { id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8 }, + { id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 8 }, + { id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 8 }, + { id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 }, + { id: "restoreStatus", title: "복구상태", widthPercent: 8 }, + { id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 }, + { id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 }, +] as const; + +const toDisplay = (value: unknown) => { + const normalized = String(value ?? "").trim(); + return normalized ? normalized : "-"; +}; + +const toCoordStatusDisplay = (value: unknown) => { + const normalized = String(value ?? "").trim().toUpperCase(); + if (normalized === "SUCCESS") return "성공"; + if (normalized === "NO_ADDRESS") return "주소없음"; + if (normalized === "FAILED") return "실패"; + return normalized || "-"; +}; + +const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SIZE) => + items.map((item, index) => ({ + ...item, + rowNo: page * pageSize + index + 1, + sizeWidthDisplay: toDisplay(item.sizeWidth), + sizeLengthDisplay: toDisplay(item.sizeLength), + sizeDepthDisplay: toDisplay(item.sizeDepth), + detailCauseDisplay: toDisplay(item.detailCause), + coordStatusDisplay: toCoordStatusDisplay(item.coordStatus), + restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd), + })); + +const buildDownloadRows = (rows: SinkholeTableRow[]) => + rows.map((row) => ({ + rowNo: row.rowNo, + ocrnYmd: row.ocrnYmd || "-", + fullAddress: row.fullAddress || "-", + sizeWidth: row.sizeWidthDisplay, + sizeLength: row.sizeLengthDisplay, + sizeDepth: row.sizeDepthDisplay, + detailCause: row.detailCauseDisplay, + restoreStatus: row.restoreStatus || "-", + restoreCompletedYmd: row.restoreCompletedYmdDisplay, + })); + +const unwrapPayload = (response: any): T => + (response?.data ?? response) as T; + +export default function SinkholePopupPage() { + const searchParams = useSearchParams(); + const hasInitialFilterParams = + searchParams.has("startYmd") || + searchParams.has("endYmd") || + searchParams.has("ctpvCd") || + searchParams.has("sggCd") || + searchParams.has("restoreStatus") || + searchParams.has("coordStatus"); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(() => { + const seeded = normalizeSinkholeFilters( + { + startYmd: searchParams.get("startYmd") ?? "", + endYmd: searchParams.get("endYmd") ?? "", + ctpvCd: searchParams.get("ctpvCd") ?? "0", + sggCd: searchParams.get("sggCd") ?? "0", + restoreStatus: searchParams.get("restoreStatus") ?? "0", + coordStatus: searchParams.get("coordStatus") ?? "0", + }, + undefined, + ); + + if (!hasInitialFilterParams) { + return normalizeSinkholeFilters(buildSinkholeDefaultFilters(), undefined); + } + return seeded; + }); + const [appliedFilters, setAppliedFilters] = useState(() => { + const seeded = normalizeSinkholeFilters( + { + startYmd: searchParams.get("startYmd") ?? "", + endYmd: searchParams.get("endYmd") ?? "", + ctpvCd: searchParams.get("ctpvCd") ?? "0", + sggCd: searchParams.get("sggCd") ?? "0", + restoreStatus: searchParams.get("restoreStatus") ?? "0", + coordStatus: searchParams.get("coordStatus") ?? "0", + }, + undefined, + ); + + if (!hasInitialFilterParams) { + return normalizeSinkholeFilters(buildSinkholeDefaultFilters(), undefined); + } + return seeded; + }); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + const sggOptions = useMemo( + () => + filters.ctpvCd === "0" + ? sgg + : sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))), + [filters.ctpvCd, sgg], + ); + + useEffect(() => { + if (ctpv.length === 0) fetchCtpv(); + if (sgg.length === 0) fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + const fetchRows = useCallback( + async ( + page: number, + nextFilters: SinkholeFilters, + pageSize = PAGE_SIZE, + options?: { + includeCoordStatus?: boolean; + }, + ) => { + const queryString = buildSinkholeListQueryString( + nextFilters, + ctpv as SelectDataType[], + sgg as SelectDataType[], + page - 1, + pageSize, + options, + ); + const response = await getRawJson(`/sewerage-service/api/v1/sinkholes?${queryString}`); + const payload = unwrapPayload<{ items?: SinkholeListItem[]; total?: number }>(response); + const items = Array.isArray(payload?.items) ? payload.items : []; + return { + rows: toTableRows(items, page - 1, pageSize), + items, + total: Number(payload?.total ?? 0), + }; + }, + [ctpv, sgg], + ); + + const loadRows = useCallback( + async (page = 1, nextFilters = filters) => { + setIsLoading(true); + try { + const normalized = normalizeSinkholeFilters(nextFilters); + const response = await fetchRows(page, normalized); + setRows(response.rows); + setTotal(response.total); + setCurrentPage(page); + setAppliedFilters(normalized); + window.opener?.postMessage( + { + type: SINKHOLE_FILTER_SYNC_MESSAGE, + filters: normalized, + }, + window.location.origin, + ); + } catch (error) { + console.error("지반침하 목록 조회 중 오류:", error); + setRows([]); + setTotal(0); + alert(error instanceof Error ? error.message : "지반침하 목록 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }, + [fetchRows, filters], + ); + + useEffect(() => { + void loadRows(1, filters); + // 최초 로딩은 한 번만 수행한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSearch = async (page = 1) => { + const normalized = normalizeSinkholeFilters(filters); + setFilters(normalized); + await loadRows(page, normalized); + }; + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + setSelectedIndex(index); + window.opener?.postMessage( + { + type: SINKHOLE_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); + }; + + const handleDownload = async () => { + if (total === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + setIsLoading(true); + try { + const normalized = normalizeSinkholeFilters(appliedFilters); + const downloadItems: SinkholeListItem[] = []; + const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE)); + + for (let page = 1; page <= totalPages; page += 1) { + const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, { + includeCoordStatus: false, + }); + downloadItems.push(...response.items); + } + + if (downloadItems.length === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + const downloadRows = toTableRows(downloadItems, 0, 1); + await downloadExcelWithKoreanHeaders({ + data: buildDownloadRows(downloadRows) as unknown as JSON[], + fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`, + sheetNameArray: ["지반침하정보"], + headerRange: "A1:I1", + headerMapping: SINKHOLE_DOWNLOAD_EXCEL_HEADERS, + }); + } catch (error) { + console.error("지반침하 다운로드 중 오류:", error); + alert(error instanceof Error ? error.message : "지반침하 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+

지반침하발생현황

+
+
+ +
+
+ + + + + + + + + + + + +
+ + +
+
+ + + headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={HEADLIST as any} + pageSize={PAGE_SIZE} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => void handleSearch(page)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + isHideCount={true} + /> +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts new file mode 100644 index 000000000..eecd5606c --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts @@ -0,0 +1,338 @@ +"use client"; + +import type { SelectDataType } from "@common/business_components"; +import type { IUserInfo } from "@/shared/store"; + +export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE"; +export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info"; +export const SINKHOLE_LAYER_NAME = "지반침하정보"; +export const SINKHOLE_POPUP_KEY = "sinkholePopup"; +export const SINKHOLE_ROW_SELECT_MESSAGE = "SN_SINKHOLE_ROW_SELECT"; +export const SINKHOLE_FILTER_SYNC_MESSAGE = "SN_SINKHOLE_FILTER_SYNC"; +export const SINKHOLE_SYNC_COMPLETED_MESSAGE = "SN_SINKHOLE_SYNC_COMPLETED"; +export const SINKHOLE_DEFAULT_RADIUS = 100; +export const SINKHOLE_LAYER_ID = "SN_SINKHOLE_LAYER"; +export const SINKHOLE_SELECTED_LAYER_ID = "SN_SINKHOLE_SELECTED_LAYER"; +export const SINKHOLE_MIN_SEARCH_YMD = "20160101"; +export const SINKHOLE_MIN_SEARCH_YEAR = Number(SINKHOLE_MIN_SEARCH_YMD.slice(0, 4)); + +export type SinkholeFilters = { + startYmd: string; + endYmd: string; + ctpvCd: string; + sggCd: string; + restoreStatus: string; + coordStatus: string; +}; + +export type SinkholeListItem = { + acdntNo: string; + ctpvNm?: string; + sggNm?: string; + emdNm?: string; + fullAddress: string; + ocrnYmd: string; + restoreStatus?: string; + restoreCompletedYmd?: string; + coordStatus?: string; + geocodeStatus?: string; + geocodeSource?: string; + geocodeErrorMessage?: string; + latitude?: number | null; + longitude?: number | null; + x?: number | null; + y?: number | null; + sizeWidth?: number | string | null; + sizeLength?: number | string | null; + sizeDepth?: number | string | null; + detailCause?: string | null; + deathCount?: number | null; + injuryCount?: number | null; + vehicleDamageCount?: number | null; +}; + +export type SinkholeDetail = SinkholeListItem & { + daddr?: string; +}; + +export type SinkholeNearbyPipe = { + pipeId: string; + fid: string; + lyrTypeNm: string; + pipeTypeCd?: string; + pipeTypeName?: string; + diameter?: number | string | null; + length?: number | string | null; + elapsedYears?: number | string | null; + distanceMeters?: number | string | null; +}; + +export type SinkholeSyncSummary = { + syncId: string; + startedAt: string; + finishedAt: string; + sourceCount: number; + successCount: number; + failedCount: number; + reGeocodedCount: number; + message: string; +}; + +export type SinkholeScope = { + ctpvCd: string; + sggCd: string; + ctpvLocked: boolean; + sggLocked: boolean; +}; + +export type SinkholeYearRange = { + startYear: string; + endYear: string; +}; + +const toYmd = (date: Date) => + `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`; + +export const buildSinkholeDefaultYearRange = (baseDate = new Date()): SinkholeYearRange => { + const currentYear = baseDate.getFullYear(); + return { + startYear: String(currentYear - 1), + endYear: String(currentYear), + }; +}; + +export const normalizeSinkholeYearRange = ( + range?: Partial | null, + fallback: SinkholeYearRange = buildSinkholeDefaultYearRange(), +): SinkholeYearRange => ({ + startYear: String(range?.startYear ?? fallback.startYear).replace(/[^0-9]/g, "").slice(0, 4), + endYear: String(range?.endYear ?? fallback.endYear).replace(/[^0-9]/g, "").slice(0, 4), +}); + +export const validateSinkholeYearRange = ( + range: SinkholeYearRange, + currentYear = new Date().getFullYear(), +) => { + const startYear = Number(range.startYear); + const endYear = Number(range.endYear); + + if (!/^\d{4}$/.test(range.startYear) || !/^\d{4}$/.test(range.endYear)) { + return "시작년도와 종료년도는 4자리 숫자로 입력해야 합니다."; + } + if (startYear < SINKHOLE_MIN_SEARCH_YEAR || endYear < SINKHOLE_MIN_SEARCH_YEAR) { + return `${SINKHOLE_MIN_SEARCH_YEAR}년 이후 범위로 입력해야 합니다.`; + } + if (startYear > endYear) { + return "시작년도는 종료년도보다 클 수 없습니다."; + } + if (endYear > currentYear) { + return `종료년도는 현재년도(${currentYear})를 초과할 수 없습니다.`; + } + + return null; +}; + +export const buildSinkholeFiltersFromYearRange = ( + range: SinkholeYearRange, + scope?: SinkholeScope, +): SinkholeFilters => { + const normalized = normalizeSinkholeYearRange(range); + return { + startYmd: `${normalized.startYear}0101`, + endYmd: `${normalized.endYear}1231`, + ctpvCd: scope?.ctpvCd || "0", + sggCd: scope?.sggCd || "0", + restoreStatus: "0", + coordStatus: "0", + }; +}; + +export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => { + const today = new Date(); + const oneYearAgo = new Date(today); + oneYearAgo.setFullYear(today.getFullYear() - 1); + + return { + startYmd: toYmd(oneYearAgo), + endYmd: toYmd(today), + ctpvCd: scope?.ctpvCd || "0", + sggCd: scope?.sggCd || "0", + restoreStatus: "0", + coordStatus: "0", + }; +}; + +export const buildSinkholeScope = (userInfo: IUserInfo | null): SinkholeScope => { + const admin = userInfo?.adminRoleYn === "Y"; + if (admin) { + return { + ctpvCd: "0", + sggCd: "0", + ctpvLocked: false, + sggLocked: false, + }; + } + + const digits = String(userInfo?.laeSeCd ?? "").replace(/[^0-9]/g, ""); + if (digits.length >= 5) { + return { + ctpvCd: digits.slice(0, 2), + sggCd: digits.slice(0, 5), + ctpvLocked: true, + sggLocked: true, + }; + } + if (digits.length >= 2) { + return { + ctpvCd: digits.slice(0, 2), + sggCd: "0", + ctpvLocked: true, + sggLocked: false, + }; + } + + return { + ctpvCd: "0", + sggCd: "0", + ctpvLocked: false, + sggLocked: false, + }; +}; + +export const normalizeSinkholeFilters = ( + filters: SinkholeFilters, + scope?: SinkholeScope, +): SinkholeFilters => { + const next = { + startYmd: String(filters.startYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8), + endYmd: String(filters.endYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8), + ctpvCd: String(filters.ctpvCd ?? "0") || "0", + sggCd: String(filters.sggCd ?? "0") || "0", + restoreStatus: String(filters.restoreStatus ?? "0") || "0", + coordStatus: String(filters.coordStatus ?? "0") || "0", + }; + + if (scope?.ctpvCd && scope.ctpvCd !== "0") { + next.ctpvCd = scope.ctpvCd; + } + if (scope?.sggCd && scope.sggCd !== "0") { + next.sggCd = scope.sggCd; + } else if (next.ctpvCd !== "0" && next.sggCd !== "0" && !next.sggCd.startsWith(next.ctpvCd)) { + next.sggCd = "0"; + } + + return next; +}; + +export const formatYmdToDateInput = (value?: string) => { + const normalized = String(value ?? "").replace(/[^0-9]/g, ""); + if (normalized.length !== 8) return ""; + return `${normalized.slice(0, 4)}-${normalized.slice(4, 6)}-${normalized.slice(6, 8)}`; +}; + +export const formatDateInputToYmd = (value?: string) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + +export const resolveRegionNameByCode = ( + code: string, + options: SelectDataType[], + length: number, +) => { + const normalized = String(code ?? "").trim(); + if (!normalized || normalized === "0") return ""; + const key = normalized.slice(0, length); + return options.find((item) => String(item.val).trim() === key)?.text ?? ""; +}; + +export const buildSinkholeListQueryString = ( + filters: SinkholeFilters, + ctpvOptions: SelectDataType[], + sggOptions: SelectDataType[], + page = 0, + size = 20, + options?: { + includeCoordStatus?: boolean; + }, +) => { + const params = new URLSearchParams({ + page: String(page), + size: String(size), + }); + + if (filters.startYmd) params.set("startYmd", filters.startYmd); + if (filters.endYmd) params.set("endYmd", filters.endYmd); + + const ctpvNm = resolveRegionNameByCode(filters.ctpvCd, ctpvOptions, 2); + const sggNm = resolveRegionNameByCode(filters.sggCd, sggOptions, 5); + if (ctpvNm) params.set("ctpvNm", ctpvNm); + if (sggNm) params.set("sggNm", sggNm); + if (filters.restoreStatus && filters.restoreStatus !== "0") { + params.set("restoreStatus", filters.restoreStatus); + } + if (filters.coordStatus && filters.coordStatus !== "0") { + params.set("coordStatus", filters.coordStatus); + } + if (options?.includeCoordStatus === false) { + params.set("includeCoordStatus", "false"); + } + + return params.toString(); +}; + +export const isSinkholeLayerNode = (node: any) => { + const data = node?.data ?? node; + const nodeId = String(node?.id ?? data?.lyrSn ?? ""); + const layerName = String(data?.lyrNm ?? data?.name ?? "").trim(); + const layerTypeName = String(data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "").trim(); + + return ( + nodeId === SINKHOLE_CONTROL_NODE_ID || + layerName === SINKHOLE_LAYER_NAME || + layerTypeName === SINKHOLE_LAYER_TYPE + ); +}; + +export const isSinkholeRootNode = (node: any) => { + const data = node?.data ?? node; + const nodeId = String(node?.id ?? data?.lyrSn ?? ""); + const layerName = String(data?.lyrNm ?? data?.name ?? "").trim(); + return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME; +}; + + +export const SINKHOLE_RESTORE_STATUS_OPTIONS = [ + { value: "0", label: "전체" }, + { value: "복구완료", label: "복구완료" }, + { value: "복구중", label: "복구중" }, + { value: "미복구", label: "미복구" }, +] as const; + +export const SINKHOLE_COORD_STATUS_OPTIONS = [ + { value: "0", label: "전체" }, + { value: "SUCCESS", label: "성공" }, + { value: "FAILED", label: "실패" }, +] as const; + +export const SINKHOLE_EXCEL_HEADERS: Record = { + rowNo: "NO", + ocrnYmd: "발생일자", + fullAddress: "주소", + sizeWidth: "발생규모폭", + sizeLength: "발생규모연장", + sizeDepth: "발생규모깊이", + detailCause: "상세발생원인", + restoreStatus: "복구상태", + coordStatus: "좌표보정상태", + restoreCompletedYmd: "복구완료일자", +}; + +export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record = { + rowNo: "NO", + ocrnYmd: "발생일자", + fullAddress: "주소", + sizeWidth: "발생규모폭", + sizeLength: "발생규모연장", + sizeDepth: "발생규모깊이", + detailCause: "상세발생원인", + restoreStatus: "복구상태", + restoreCompletedYmd: "복구완료일자", +}; diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx new file mode 100644 index 000000000..ed21c01d1 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -0,0 +1,908 @@ +"use client"; + +import { Fragment, useEffect, useMemo, useState } from "react"; +import UiPageNations from "@common/business_components/ui/table/uiPageNations"; + +import { Button } from "@common/components"; + +import { + WINDOW_POPUP_MESSAGE_TYPE, + type WindowPopupContentProps, +} from "@/features/windowPopup/model/types"; +import { + buildWaterRiskMethodLabel, + buildWaterRiskSettingAreaName, + buildWaterRiskStatusLabel, + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + WATER_RISK_RESULT_ROWS, + WATER_RISK_SETTING_ROWS, + WATER_RISK_SGG_OPTIONS_BY_SIDO, + WATER_RISK_SIDO_OPTIONS, + type WaterRiskResultRow, + type WaterRiskSessionSnapshot, + type WaterRiskSettingRow, + type WaterRiskStagedPipe, +} from "./waterRiskTypes"; + +type WaterRiskView = "result" | "setting"; + +type ResultFilters = { + analysisDate: string; + analysisTime: string; + sidoName: string; + sggName: string; + areaId: string; + riskStatus: "ALL" | "DANGER" | "NORMAL"; +}; + +type SettingFilters = { + sidoName: string; + sggName: string; + areaId: string; +}; + +const DEFAULT_RESULT_FILTERS: ResultFilters = { + analysisDate: "2026-05-18", + analysisTime: "09:00", + sidoName: "전체", + sggName: "전체", + areaId: "ALL", + riskStatus: "ALL", +}; + +const DEFAULT_SETTING_FILTERS: SettingFilters = { + sidoName: "전체", + sggName: "전체", + areaId: "ALL", +}; + +const TOOLBAR_INPUT_CLASS_NAME = + "sn-popup-input"; + +const SECONDARY_BUTTON_CLASS_NAME = + "sn-popup-secondary-button"; + +const ACTION_BUTTON_CLASS_NAME = + "sn-popup-primary-button"; +const WATER_RISK_PAGE_SIZE = 10; +const RESULT_TABLE_CONTAINER_CLASS_NAME = + "sn-popup-card min-h-0"; +const SETTING_TABLE_CONTAINER_CLASS_NAME = + "sn-popup-card min-h-0"; + +const postMessageToOpener = (type: string, payload?: Record) => { + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage({ type, payload }, window.location.origin); +}; + +const buildPreviewTimestamp = () => new Date().toISOString(); + +const buildResultDownloadPreview = ( + filters: ResultFilters, + rows: WaterRiskResultRow[], +) => ({ + endpoint: "/api/v1/map/wraResults/download", + method: "POST", + generatedAt: buildPreviewTimestamp(), + filters, + resultCount: rows.length, +}); + +const buildSettingDownloadPreview = ( + filters: SettingFilters, + rows: WaterRiskSettingRow[], +) => ({ + endpoint: "/api/v1/map/wraOutletPipeSettings/download", + method: "POST", + generatedAt: buildPreviewTimestamp(), + filters, + resultCount: rows.length, +}); + +const buildSavePreview = ( + snapshot: WaterRiskSessionSnapshot, + stagedPipes: WaterRiskStagedPipe[], +) => ({ + endpoint: "/api/v1/map/wraOutletPipeSettings/save", + method: "POST", + generatedAt: buildPreviewTimestamp(), + body: { + focusAreaId: snapshot.selectedSettingAreaId, + focusAreaName: snapshot.selectedSettingAreaName, + pipes: stagedPipes.map((pipe, index) => ({ + pipeId: pipe.pipeId, + setSeq: index + 1, + })), + }, +}); + +const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => + riskStatus === "DANGER" + ? "sn-popup-badge-danger" + : "sn-popup-badge-normal"; + +export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { + const [activeView, setActiveView] = useState("result"); + const [snapshot, setSnapshot] = useState( + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + ); + const [draftResultFilters, setDraftResultFilters] = useState( + DEFAULT_RESULT_FILTERS, + ); + const [appliedResultFilters, setAppliedResultFilters] = useState( + DEFAULT_RESULT_FILTERS, + ); + const [draftSettingFilters, setDraftSettingFilters] = useState( + DEFAULT_SETTING_FILTERS, + ); + const [appliedSettingFilters, setAppliedSettingFilters] = useState( + DEFAULT_SETTING_FILTERS, + ); + const [selectedResultAreaId, setSelectedResultAreaId] = useState( + WATER_RISK_RESULT_ROWS[0]?.areaId ?? "", + ); + const [selectedSettingAreaId, setSelectedSettingAreaId] = useState(""); + const [resultCurrentPage, setResultCurrentPage] = useState(1); + const [settingCurrentPage, setSettingCurrentPage] = useState(1); + const [noticeMessage, setNoticeMessage] = useState(""); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return; + + const nextSnapshot = (event.data.payload ?? + EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot; + setSnapshot(nextSnapshot); + if (nextSnapshot.selectedAreaId) { + setSelectedResultAreaId(nextSnapshot.selectedAreaId); + } + if (nextSnapshot.selectedSettingAreaId) { + setSelectedSettingAreaId(nextSnapshot.selectedSettingAreaId); + } + }; + + window.addEventListener("message", handleMessage); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY); + + return () => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT); + window.removeEventListener("message", handleMessage); + }; + }, []); + + const visibleResultRows = useMemo(() => { + return WATER_RISK_RESULT_ROWS.filter((row) => { + if ( + appliedResultFilters.sidoName !== "전체" && + row.sidoName !== appliedResultFilters.sidoName + ) { + return false; + } + + if ( + appliedResultFilters.sggName !== "전체" && + row.sggName !== appliedResultFilters.sggName + ) { + return false; + } + + if ( + appliedResultFilters.areaId !== "ALL" && + row.areaId !== appliedResultFilters.areaId + ) { + return false; + } + + if ( + appliedResultFilters.riskStatus !== "ALL" && + row.riskStatus !== appliedResultFilters.riskStatus + ) { + return false; + } + + return true; + }); + }, [appliedResultFilters]); + + const visibleSettingRows = useMemo(() => { + return WATER_RISK_SETTING_ROWS.filter((row) => { + if ( + appliedSettingFilters.sidoName !== "전체" && + row.sidoName !== appliedSettingFilters.sidoName + ) { + return false; + } + + if ( + appliedSettingFilters.sggName !== "전체" && + row.sggName !== appliedSettingFilters.sggName + ) { + return false; + } + + if ( + appliedSettingFilters.areaId !== "ALL" && + row.areaId !== appliedSettingFilters.areaId + ) { + return false; + } + + return true; + }); + }, [appliedSettingFilters]); + + const maxPipeCount = useMemo( + () => + visibleResultRows.reduce( + (maxCount, row) => Math.max(maxCount, row.pipeSummaries.length), + 0, + ), + [visibleResultRows], + ); + + const resultAreaOptions = useMemo( + () => + WATER_RISK_RESULT_ROWS.map((row) => ({ + areaId: row.areaId, + areaName: row.areaName, + })), + [], + ); + + const settingAreaOptions = useMemo(() => { + const areaMap = new Map(); + WATER_RISK_SETTING_ROWS.forEach((row) => { + areaMap.set(row.areaId, { + areaId: row.areaId, + areaName: row.areaName, + }); + }); + return Array.from(areaMap.values()); + }, []); + + const resultSggOptions = + WATER_RISK_SGG_OPTIONS_BY_SIDO[draftResultFilters.sidoName] ?? + WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ?? + []; + const settingSggOptions = + WATER_RISK_SGG_OPTIONS_BY_SIDO[draftSettingFilters.sidoName] ?? + WATER_RISK_SGG_OPTIONS_BY_SIDO["전체"] ?? + []; + + const currentSettingAreaName = + snapshot.selectedSettingAreaName || + buildWaterRiskSettingAreaName(selectedSettingAreaId || snapshot.selectedSettingAreaId); + + const pagedResultRows = useMemo(() => { + const startIndex = (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE; + return visibleResultRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE); + }, [resultCurrentPage, visibleResultRows]); + + const pagedSettingRows = useMemo(() => { + const startIndex = (settingCurrentPage - 1) * WATER_RISK_PAGE_SIZE; + return visibleSettingRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE); + }, [settingCurrentPage, visibleSettingRows]); + + const showSettingEditor = snapshot.isSettingEditMode || snapshot.stagedPipes.length > 0; + const resultPipeGroupCount = Math.max(1, maxPipeCount); + const resultTableMinWidth = 560 + resultPipeGroupCount * 180; + const settingTableMinWidth = 900; + + useEffect(() => { + if (typeof window === "undefined" || !window.resizeTo) return; + + const popupWidth = + activeView === "result" + ? Math.min(1320, Math.max(860, 620 + resultPipeGroupCount * 180)) + : showSettingEditor + ? 1180 + : 980; + const popupHeight = activeView === "setting" && showSettingEditor ? 900 : 720; + + window.resizeTo(popupWidth, popupHeight); + }, [activeView, resultPipeGroupCount, showSettingEditor]); + + const handleApplyResultFilters = () => { + if (!draftResultFilters.analysisDate || !draftResultFilters.analysisTime) { + setNoticeMessage("기준 날짜와 시간을 입력해야 조회할 수 있습니다."); + return; + } + + setResultCurrentPage(1); + setAppliedResultFilters(draftResultFilters); + setNoticeMessage("통수위험 분석 결과를 갱신했습니다."); + }; + + const handleOpenSettingView = () => { + setActiveView("setting"); + setNoticeMessage(""); + }; + + const handleReturnToResultView = () => { + setActiveView("result"); + setNoticeMessage(""); + }; + + const handleSelectResultRow = (row: WaterRiskResultRow) => { + setSelectedResultAreaId(row.areaId); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, { + areaId: row.areaId, + }); + setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`); + }; + + const handlePreviewResultDownload = () => { + const preview = buildResultDownloadPreview(appliedResultFilters, visibleResultRows); + console.info("WaterRisk result download preview", preview); + setNoticeMessage("결과 다운로드 preview를 생성했습니다. 콘솔을 확인하세요."); + }; + + const handleApplySettingFilters = () => { + setSettingCurrentPage(1); + setAppliedSettingFilters(draftSettingFilters); + setNoticeMessage("설정 현황 목록을 갱신했습니다."); + }; + + const handleStopSettingEdit = () => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT); + setNoticeMessage("통수위험 설정 편집 모드를 종료했습니다."); + }; + + const handleRemoveStagedPipe = (pipeId: string) => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE, { + pipeId, + }); + }; + + const handleMoveStagedPipe = (pipeId: string, direction: "UP" | "DOWN") => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE, { + pipeId, + direction, + }); + }; + + const handleClearStagedPipes = () => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES); + }; + + const handlePreviewSave = () => { + if (!snapshot.selectedSettingAreaId || snapshot.stagedPipes.length === 0) { + setNoticeMessage("저장할 유출관로가 없습니다."); + return; + } + + const preview = buildSavePreview(snapshot, snapshot.stagedPipes); + console.info("WaterRisk save preview", preview); + setNoticeMessage("설정 저장 payload preview를 생성했습니다. 콘솔을 확인하세요."); + }; + + const handleSelectSettingArea = (areaId: string) => { + setSelectedSettingAreaId(areaId); + + const targetAreaName = + settingAreaOptions.find((item) => item.areaId === areaId)?.areaName ?? + buildWaterRiskSettingAreaName(areaId); + + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT, { + areaId, + areaName: targetAreaName, + }); + setNoticeMessage(`${targetAreaName} 설정 편집 대상을 선택했습니다.`); + }; + + return ( +
+
+ {activeView === "setting" ? ( + + ) : null} +

+ {activeView === "result" ? "통수위험분석" : "통수위험분석 설정"} +

+
+ +
+ {activeView === "result" ? ( +
+
+ + + + + + +
+ + +
+
+ +
+

+ 통수위험 분석 결과 {visibleResultRows.length.toLocaleString("ko-KR")}건 +

+
+ +
+
+ + + + + + + + + {Array.from({ length: maxPipeCount }).map((_, index) => ( + + ))} + + + + {Array.from({ length: maxPipeCount }).map((_, index) => ( + + + + + ))} + + + + {pagedResultRows.map((row, index) => { + const isSelected = row.areaId === selectedResultAreaId; + const rowNumber = + (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE + index + 1; + + return ( + handleSelectResultRow(row)} + > + + + + + + {Array.from({ length: maxPipeCount }).map((_, pipeIndex) => { + const pipe = row.pipeSummaries[pipeIndex]; + return ( + + + + + ); + })} + + + ); + })} + +
no시도시군구중점관리지역예측강우량(mm) + 관로 ID {index + 1} + 통수위험
유출유량(㎥/hr)통수량(㎥/hr)
{rowNumber}{row.sidoName}{row.sggName}{row.areaName}{row.forecastRainQty.toFixed(1)} + {pipe ? pipe.runoffFlowQty.toFixed(1) : ""} + + {pipe ? pipe.conveyQty.toFixed(1) : ""} + + + {buildWaterRiskStatusLabel(row.riskStatus)} + +
+
+
+ + {visibleResultRows.length > WATER_RISK_PAGE_SIZE ? ( +
+ +
+ ) : null} +
+ ) : ( +
+
+ + + +
+ +
+
+ +
+

+ 통수위험 설정 현황 {visibleSettingRows.length.toLocaleString("ko-KR")}건 +

+
+ +
+
+ + + + + + + + + + + + + + + + {pagedSettingRows.map((row, index) => { + const isSelected = + row.areaId === (selectedSettingAreaId || snapshot.selectedSettingAreaId); + + return ( + handleSelectSettingArea(row.areaId)} + > + + + + + + + + + + + ); + })} + +
시도시군구중점관리지역유출관로ID관경연장재질설정순번설정완료여부
{row.sidoName}{row.sggName}{row.areaName}{row.pipeId}{row.pipeDia}{row.pipeLen}{row.pipeMaterial}{row.setSeq} + {row.setCompleteYn === "Y" ? "완료" : "미완료"} +
+
+
+ + {visibleSettingRows.length > WATER_RISK_PAGE_SIZE ? ( +
+ +
+ ) : null} + + {showSettingEditor ? ( +
+
+
+
+ 설정 편집 대상: {currentSettingAreaName || "선택 전"} +
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + {snapshot.stagedPipes.length === 0 ? ( + + + + ) : ( + snapshot.stagedPipes.map((pipe, index) => ( + + + + + + + + + + )) + )} + +
순번유출관로ID관경연장재질선택원천조작
+ staged 유출관로가 없습니다. 지도에서 하수관로를 선택하세요. +
{index + 1}{pipe.pipeId}{pipe.pipeDia}{pipe.pipeLen}{pipe.pipeMaterial}{pipe.source} +
+ + + +
+
+
+
+
+ ) : null} +
+ )} +
+
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts new file mode 100644 index 000000000..ad94da2e3 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts @@ -0,0 +1,936 @@ +"use client"; + +export type WaterRiskStatus = "DANGER" | "NORMAL"; +export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2"; +export type WaterRiskTab = "result" | "setting"; + +export type WaterRiskPipeSummary = { + pipeId: string; + runoffFlowQty: number; + conveyQty: number; +}; + +export type WaterRiskResultRow = { + areaId: string; + areaName: string; + sidoName: string; + sggName: string; + forecastRainQty: number; + riskStatus: WaterRiskStatus; + calculationMethod: WaterRiskCalculationMethod; + pipeSummaries: WaterRiskPipeSummary[]; +}; + +export type WaterRiskSettingRow = { + areaId: string; + areaName: string; + sidoName: string; + sggName: string; + pipeId: string; + pipeDia: string; + pipeLen: string; + pipeMaterial: string; + setSeq: number; + setCompleteYn: "Y" | "N"; +}; + +export type WaterRiskStagedPipe = { + pipeId: string; + pipeDia: string; + pipeLen: string; + pipeMaterial: string; + setSeq: number; + source: "MAP" | "MOCK"; +}; + +export type WaterRiskGridCell = { + gridId: string; + rainQty: number; + colorLevel: "LEVEL_1" | "LEVEL_2" | "LEVEL_3" | "LEVEL_4"; + polygonCoordinates: number[][][]; +}; + +export type WaterRiskAreaMapData = { + areaId: string; + areaName: string; + polygonCoordinates: number[][][]; + gridCells: WaterRiskGridCell[]; +}; + +export type WaterRiskMapPipe = { + pipeId: string; + displayLabel: string; + lineCoordinates: number[][]; +}; + +export type WaterRiskSessionSnapshot = { + selectedAreaId: string | null; + selectedSettingAreaId: string | null; + selectedSettingAreaName: string; + isSettingEditMode: boolean; + stagedPipes: WaterRiskStagedPipe[]; + statusMessage: string; +}; + +export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = { + selectedAreaId: null, + selectedSettingAreaId: null, + selectedSettingAreaName: "", + isSettingEditMode: false, + stagedPipes: [], + statusMessage: "", +}; + +const buildRectanglePolygon = ( + centerX: number, + centerY: number, + halfWidth: number, + halfHeight: number, +) => { + return [[ + [centerX - halfWidth, centerY - halfHeight], + [centerX + halfWidth, centerY - halfHeight], + [centerX + halfWidth, centerY + halfHeight], + [centerX - halfWidth, centerY + halfHeight], + [centerX - halfWidth, centerY - halfHeight], + ]]; +}; + +const buildGridCells = ( + centerX: number, + centerY: number, + values: number[], +): WaterRiskGridCell[] => { + const cellHalfSize = 180; + const offsets = [ + [-190, 190], + [190, 190], + [-190, -190], + [190, -190], + ] as const; + + return offsets.map(([offsetX, offsetY], index) => { + const rainQty = values[index] ?? values[values.length - 1] ?? 0; + const colorLevel = rainQty >= 40 + ? "LEVEL_4" + : rainQty >= 30 + ? "LEVEL_3" + : rainQty >= 20 + ? "LEVEL_2" + : "LEVEL_1"; + + return { + gridId: `GRID-${index + 1}`, + rainQty, + colorLevel, + polygonCoordinates: buildRectanglePolygon( + centerX + offsetX, + centerY + offsetY, + cellHalfSize, + cellHalfSize, + ), + }; + }); +}; + +export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 42.0, + riskStatus: "DANGER", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-10284", runoffFlowQty: 18.5, conveyQty: 15.2 }, + { pipeId: "PIPE-10291", runoffFlowQty: 11.3, conveyQty: 12.8 }, + ], + }, + { + areaId: "AREA-002", + areaName: "B 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 31.4, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-45103", runoffFlowQty: 9.8, conveyQty: 14.7 }, + ], + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 37.8, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-60014", runoffFlowQty: 16.1, conveyQty: 12.9 }, + { pipeId: "PIPE-60018", runoffFlowQty: 10.2, conveyQty: 11.1 }, + { pipeId: "PIPE-60021", runoffFlowQty: 8.7, conveyQty: 9.5 }, + ], + }, + { + areaId: "AREA-004", + areaName: "D 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 24.3, + riskStatus: "NORMAL", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-72010", runoffFlowQty: 7.2, conveyQty: 11.4 }, + { pipeId: "PIPE-72018", runoffFlowQty: 6.1, conveyQty: 9.8 }, + ], + }, + { + areaId: "AREA-005", + areaName: "E 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 28.6, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-83014", runoffFlowQty: 8.3, conveyQty: 12.1 }, + ], + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 45.1, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-83018", runoffFlowQty: 17.4, conveyQty: 12.3 }, + { pipeId: "PIPE-83019", runoffFlowQty: 13.6, conveyQty: 10.1 }, + ], + }, + { + areaId: "AREA-007", + areaName: "G 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 33.2, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-91004", runoffFlowQty: 9.7, conveyQty: 12.5 }, + ], + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 39.4, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-91010", runoffFlowQty: 15.5, conveyQty: 11.6 }, + { pipeId: "PIPE-91012", runoffFlowQty: 12.4, conveyQty: 10.8 }, + { pipeId: "PIPE-91014", runoffFlowQty: 9.2, conveyQty: 9.0 }, + ], + }, + { + areaId: "AREA-009", + areaName: "I 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 26.1, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-99001", runoffFlowQty: 6.7, conveyQty: 10.4 }, + ], + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 41.8, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-99007", runoffFlowQty: 14.8, conveyQty: 10.9 }, + { pipeId: "PIPE-99009", runoffFlowQty: 10.1, conveyQty: 9.7 }, + ], + }, + { + areaId: "AREA-011", + areaName: "K 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 29.7, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-11001", runoffFlowQty: 8.8, conveyQty: 12.5 }, + { pipeId: "PIPE-11002", runoffFlowQty: 7.9, conveyQty: 11.4 }, + ], + }, + { + areaId: "AREA-012", + areaName: "L 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 43.6, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-12001", runoffFlowQty: 16.7, conveyQty: 12.2 }, + ], + }, + { + areaId: "AREA-013", + areaName: "M 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 35.2, + riskStatus: "DANGER", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-13001", runoffFlowQty: 14.4, conveyQty: 11.6 }, + { pipeId: "PIPE-13002", runoffFlowQty: 10.5, conveyQty: 10.9 }, + { pipeId: "PIPE-13003", runoffFlowQty: 8.6, conveyQty: 9.3 }, + ], + }, + { + areaId: "AREA-014", + areaName: "N 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 21.9, + riskStatus: "NORMAL", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-14001", runoffFlowQty: 5.8, conveyQty: 8.7 }, + ], + }, + { + areaId: "AREA-015", + areaName: "O 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 47.3, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-15001", runoffFlowQty: 18.2, conveyQty: 13.4 }, + { pipeId: "PIPE-15002", runoffFlowQty: 12.7, conveyQty: 10.8 }, + ], + }, + { + areaId: "AREA-016", + areaName: "P 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 27.5, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-16001", runoffFlowQty: 7.1, conveyQty: 10.3 }, + { pipeId: "PIPE-16002", runoffFlowQty: 6.2, conveyQty: 9.8 }, + ], + }, + { + areaId: "AREA-017", + areaName: "Q 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 32.8, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-17001", runoffFlowQty: 9.9, conveyQty: 11.7 }, + ], + }, + { + areaId: "AREA-018", + areaName: "R 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 44.9, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-18001", runoffFlowQty: 17.1, conveyQty: 12.4 }, + { pipeId: "PIPE-18002", runoffFlowQty: 13.4, conveyQty: 10.6 }, + { pipeId: "PIPE-18003", runoffFlowQty: 9.8, conveyQty: 9.1 }, + ], + }, + { + areaId: "AREA-019", + areaName: "S 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + forecastRainQty: 23.4, + riskStatus: "NORMAL", + calculationMethod: "METHOD_1", + pipeSummaries: [ + { pipeId: "PIPE-19001", runoffFlowQty: 6.4, conveyQty: 9.6 }, + ], + }, + { + areaId: "AREA-020", + areaName: "T 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + forecastRainQty: 40.7, + riskStatus: "DANGER", + calculationMethod: "METHOD_2", + pipeSummaries: [ + { pipeId: "PIPE-20001", runoffFlowQty: 15.2, conveyQty: 11.4 }, + { pipeId: "PIPE-20002", runoffFlowQty: 11.9, conveyQty: 10.1 }, + ], + }, +]; + +export const WATER_RISK_SETTING_ROWS: WaterRiskSettingRow[] = [ + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-10284", + pipeDia: "900 mm", + pipeLen: "186 m", + pipeMaterial: "콘크리트", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-10291", + pipeDia: "700 mm", + pipeLen: "142 m", + pipeMaterial: "흄관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-002", + areaName: "B 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-45103", + pipeDia: "600 mm", + pipeLen: "98 m", + pipeMaterial: "PE", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-60014", + pipeDia: "800 mm", + pipeLen: "154 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-60018", + pipeDia: "700 mm", + pipeLen: "119 m", + pipeMaterial: "흄관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-004", + areaName: "D 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-72010", + pipeDia: "500 mm", + pipeLen: "101 m", + pipeMaterial: "PVC", + setSeq: 1, + setCompleteYn: "N", + }, + { + areaId: "AREA-005", + areaName: "E 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-83014", + pipeDia: "650 mm", + pipeLen: "133 m", + pipeMaterial: "PE", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-83018", + pipeDia: "900 mm", + pipeLen: "176 m", + pipeMaterial: "흄관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-83019", + pipeDia: "800 mm", + pipeLen: "167 m", + pipeMaterial: "콘크리트", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-007", + areaName: "G 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91004", + pipeDia: "700 mm", + pipeLen: "111 m", + pipeMaterial: "PVC", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91010", + pipeDia: "1000 mm", + pipeLen: "201 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91012", + pipeDia: "900 mm", + pipeLen: "184 m", + pipeMaterial: "흄관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-91014", + pipeDia: "800 mm", + pipeLen: "159 m", + pipeMaterial: "콘크리트", + setSeq: 3, + setCompleteYn: "Y", + }, + { + areaId: "AREA-009", + areaName: "I 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-99001", + pipeDia: "600 mm", + pipeLen: "94 m", + pipeMaterial: "PE", + setSeq: 1, + setCompleteYn: "N", + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-99007", + pipeDia: "850 mm", + pipeLen: "143 m", + pipeMaterial: "흄관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-99009", + pipeDia: "750 mm", + pipeLen: "128 m", + pipeMaterial: "강관", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-011", + areaName: "K 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-11001", + pipeDia: "700 mm", + pipeLen: "121 m", + pipeMaterial: "콘크리트", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-012", + areaName: "L 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-12001", + pipeDia: "820 mm", + pipeLen: "147 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "N", + }, + { + areaId: "AREA-013", + areaName: "M 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-13001", + pipeDia: "900 mm", + pipeLen: "163 m", + pipeMaterial: "흄관", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-013", + areaName: "M 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-13002", + pipeDia: "800 mm", + pipeLen: "152 m", + pipeMaterial: "콘크리트", + setSeq: 2, + setCompleteYn: "Y", + }, + { + areaId: "AREA-014", + areaName: "N 중점관리지역", + sidoName: "부산광역시", + sggName: "해운대구", + pipeId: "PIPE-14001", + pipeDia: "500 mm", + pipeLen: "88 m", + pipeMaterial: "PVC", + setSeq: 1, + setCompleteYn: "Y", + }, + { + areaId: "AREA-015", + areaName: "O 중점관리지역", + sidoName: "서울특별시", + sggName: "강남구", + pipeId: "PIPE-15001", + pipeDia: "980 mm", + pipeLen: "194 m", + pipeMaterial: "강관", + setSeq: 1, + setCompleteYn: "Y", + }, +]; + +export const WATER_RISK_MAP_AREAS: WaterRiskAreaMapData[] = [ + { + areaId: "AREA-001", + areaName: "A 중점관리지역", + polygonCoordinates: buildRectanglePolygon(955300, 1948770, 360, 240), + gridCells: buildGridCells(955300, 1948770, [38.2, 42.0, 31.5, 28.4]), + }, + { + areaId: "AREA-002", + areaName: "B 중점관리지역", + polygonCoordinates: buildRectanglePolygon(954480, 1948350, 320, 210), + gridCells: buildGridCells(954480, 1948350, [22.4, 24.1, 20.5, 18.9]), + }, + { + areaId: "AREA-003", + areaName: "C 중점관리지역", + polygonCoordinates: buildRectanglePolygon(956260, 1948220, 390, 250), + gridCells: buildGridCells(956260, 1948220, [35.1, 37.8, 32.4, 29.6]), + }, + { + areaId: "AREA-004", + areaName: "D 중점관리지역", + polygonCoordinates: buildRectanglePolygon(957020, 1948660, 300, 210), + gridCells: buildGridCells(957020, 1948660, [19.3, 24.3, 21.7, 17.8]), + }, + { + areaId: "AREA-005", + areaName: "E 중점관리지역", + polygonCoordinates: buildRectanglePolygon(954900, 1949020, 290, 200), + gridCells: buildGridCells(954900, 1949020, [24.8, 28.6, 22.5, 20.7]), + }, + { + areaId: "AREA-006", + areaName: "F 중점관리지역", + polygonCoordinates: buildRectanglePolygon(955980, 1949040, 330, 220), + gridCells: buildGridCells(955980, 1949040, [41.7, 45.1, 36.2, 31.5]), + }, + { + areaId: "AREA-007", + areaName: "G 중점관리지역", + polygonCoordinates: buildRectanglePolygon(956760, 1947990, 280, 190), + gridCells: buildGridCells(956760, 1947990, [27.4, 33.2, 24.2, 21.1]), + }, + { + areaId: "AREA-008", + areaName: "H 중점관리지역", + polygonCoordinates: buildRectanglePolygon(957420, 1948090, 340, 230), + gridCells: buildGridCells(957420, 1948090, [34.7, 39.4, 32.8, 28.6]), + }, + { + areaId: "AREA-009", + areaName: "I 중점관리지역", + polygonCoordinates: buildRectanglePolygon(954020, 1947900, 260, 180), + gridCells: buildGridCells(954020, 1947900, [18.4, 26.1, 17.6, 15.9]), + }, + { + areaId: "AREA-010", + areaName: "J 중점관리지역", + polygonCoordinates: buildRectanglePolygon(957980, 1948900, 300, 210), + gridCells: buildGridCells(957980, 1948900, [37.9, 41.8, 33.8, 29.4]), + }, +]; + +export const WATER_RISK_MAP_PIPES_BY_AREA: Record = { + "AREA-001": [ + { + pipeId: "PIPE-10284", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [955020, 1948450], + [955210, 1948570], + [955320, 1948690], + ], + }, + { + pipeId: "PIPE-10291", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [955350, 1948820], + [955470, 1948680], + [955560, 1948550], + ], + }, + ], + "AREA-002": [ + { + pipeId: "PIPE-45103", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [954230, 1948500], + [954360, 1948420], + [954520, 1948340], + ], + }, + ], + "AREA-003": [ + { + pipeId: "PIPE-60014", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [956000, 1948420], + [956110, 1948320], + [956220, 1948210], + ], + }, + { + pipeId: "PIPE-60018", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [956180, 1948480], + [956270, 1948360], + [956410, 1948240], + ], + }, + { + pipeId: "PIPE-60021", + displayLabel: "관로 ID 3", + lineCoordinates: [ + [956420, 1948420], + [956520, 1948340], + [956650, 1948240], + ], + }, + ], + "AREA-004": [ + { + pipeId: "PIPE-72010", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [956770, 1948790], + [956900, 1948720], + [957050, 1948650], + ], + }, + { + pipeId: "PIPE-72018", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [956980, 1948840], + [957090, 1948740], + [957210, 1948640], + ], + }, + ], + "AREA-005": [ + { + pipeId: "PIPE-83014", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [954710, 1949160], + [954840, 1949080], + [954980, 1949000], + ], + }, + ], + "AREA-006": [ + { + pipeId: "PIPE-83018", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [955760, 1949180], + [955910, 1949100], + [956030, 1949020], + ], + }, + { + pipeId: "PIPE-83019", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [955980, 1949240], + [956120, 1949140], + [956240, 1949040], + ], + }, + ], + "AREA-007": [ + { + pipeId: "PIPE-91004", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [956520, 1948120], + [956660, 1948050], + [956820, 1947980], + ], + }, + ], + "AREA-008": [ + { + pipeId: "PIPE-91010", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [957150, 1948220], + [957300, 1948160], + [957470, 1948100], + ], + }, + { + pipeId: "PIPE-91012", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [957350, 1948290], + [957480, 1948200], + [957610, 1948110], + ], + }, + { + pipeId: "PIPE-91014", + displayLabel: "관로 ID 3", + lineCoordinates: [ + [957540, 1948340], + [957670, 1948220], + [957790, 1948100], + ], + }, + ], + "AREA-009": [ + { + pipeId: "PIPE-99001", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [953810, 1948010], + [953940, 1947960], + [954080, 1947910], + ], + }, + ], + "AREA-010": [ + { + pipeId: "PIPE-99007", + displayLabel: "관로 ID 1", + lineCoordinates: [ + [957750, 1949040], + [957900, 1948970], + [958040, 1948900], + ], + }, + { + pipeId: "PIPE-99009", + displayLabel: "관로 ID 2", + lineCoordinates: [ + [957980, 1949120], + [958120, 1949010], + [958260, 1948900], + ], + }, + ], +}; + +export const WATER_RISK_SIDO_OPTIONS = ["전체", "서울특별시", "부산광역시"]; + +export const WATER_RISK_SGG_OPTIONS_BY_SIDO: Record = { + 전체: ["전체", "강남구", "해운대구"], + 서울특별시: ["전체", "강남구"], + 부산광역시: ["전체", "해운대구"], +}; + +export const WATER_RISK_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; + +export const WATER_RISK_COLOR_LABELS: Record = { + LEVEL_1: "0.0~20.0 미만", + LEVEL_2: "20.0~30.0 미만", + LEVEL_3: "30.0~40.0 미만", + LEVEL_4: "40.0 이상", +}; + +export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => + status === "DANGER" ? "위험" : "정상"; + +export const buildWaterRiskMethodLabel = (method: WaterRiskCalculationMethod) => + method === "METHOD_1" ? "방법 1" : "방법 2"; + +export const buildWaterRiskSettingAreaName = (areaId: string | null) => { + if (!areaId) return ""; + return WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId)?.areaName ?? ""; +}; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 7618ec521..853fe2b1d 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -18,46 +18,121 @@ "use client"; import dynamic from "next/dynamic"; -import Gis from "@common/business_components/ui/map/Gis"; -import { getData, getRawJson } from "../../components/lib/api"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; + +import Collection from "ol/Collection.js"; +import Feature from "ol/Feature.js"; import GeoJSON from "ol/format/GeoJSON.js"; +import CircleGeom from "ol/geom/Circle.js"; +import Draw from "ol/interaction/Draw.js"; +import Snap from "ol/interaction/Snap.js"; +import Translate from "ol/interaction/Translate.js"; +import Fill from "ol/style/Fill.js"; +import LineString from "ol/geom/LineString.js"; import Point from "ol/geom/Point.js"; +import Polygon from "ol/geom/Polygon.js"; import VectorLayer from "ol/layer/Vector.js"; import { unByKey } from "ol/Observable.js"; import VectorSource from "ol/source/Vector.js"; +import CircleStyle from "ol/style/Circle.js"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; -import SearchModal from "./searchModal/searchModal"; -import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; -import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth"; +import Text from "ol/style/Text.js"; + +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { isWindowPopupKey, type WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import Gis from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import { Button } from "@common/components"; + +import { getData, getRawJson, postRawJson } from "../../components/lib/api"; import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; +import { useUserStore } from "@/shared/lib/use-user-store"; + import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; -import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils"; +import GisEditSessionPanel from "./components/gisEdit/GisEditSessionPanel"; +import GisEditToolbar from "./components/gisEdit/GisEditToolbar"; +import GisEditPipeSessionPopup from "./components/gisEdit/GisEditPipeSessionPopup"; +import { useGisEdit } from "./components/gisEdit/useGisEdit"; +import type { GisEditPipeBasicInfo, GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes"; import { + PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, + type PipeAnalysisDetailRow, +} from "./components/pipeAnalysis/pipeAnalysisUtils"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_GUIDE_MIN_ZOOM, AGED_PIPE_MIN_ZOOM, AGED_PIPE_LAYER_TYPE, AGED_PIPE_PAGE_SIZE, + type AgedPipeFilters, + type AgedPipeRow, buildAgedPipeCqlFilters, isAgedPipeLayerNode, matchesAgedPipeFilters, normalizeAgedPipeFilters, toDisplayPipeRow, validateElapsedYears, - type AgedPipeFilters, - type AgedPipeRow, - AGED_PIPE_DEFAULT_FILTERS, - AGED_PIPE_GUIDE_MIN_ZOOM, } from "./components/agedPipe/agedPipeUtils"; import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; -import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE } from "./components/pipeAnalysis/pipeAnalysisUtils"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap"; +import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap"; +import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; +import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; +import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel"; +import { + buildSinkholeDefaultYearRange, + buildSinkholeFiltersFromYearRange, + buildSinkholeListQueryString, + buildSinkholeScope, + isSinkholeLayerNode, + isSinkholeRootNode, + normalizeSinkholeFilters, + normalizeSinkholeYearRange, + SINKHOLE_CONTROL_NODE_ID, + SINKHOLE_DEFAULT_RADIUS, + SINKHOLE_FILTER_SYNC_MESSAGE, + SINKHOLE_LAYER_ID, + SINKHOLE_LAYER_NAME, + SINKHOLE_LAYER_TYPE, + SINKHOLE_POPUP_KEY, + SINKHOLE_ROW_SELECT_MESSAGE, + SINKHOLE_SELECTED_LAYER_ID, + SINKHOLE_SYNC_COMPLETED_MESSAGE, + type SinkholeDetail, + type SinkholeFilters, + type SinkholeListItem, + type SinkholeNearbyPipe, + type SinkholeSyncSummary, + type SinkholeYearRange, +} from "./components/sinkhole/sinkholeTypes"; +import { + FLOOD_HISTORY_AREA_LAYER_ID, + FLOOD_HISTORY_DEPTH_LAYER_ID, + FLOOD_HISTORY_LAYER_TYPE, + FLOOD_HISTORY_ROW_SELECT_MESSAGE, + FLOOD_HISTORY_POPUP_KEY, + isFloodHistoryNode, + type FloodHistoryItem, +} from "./components/floodHistory/floodHistoryTypes"; +import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup"; +import SearchModal from "./searchModal/searchModal"; +import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "./waterDepth/waterDepth"; +import { + buildWaterRiskStatusLabel, + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + WATER_RISK_MAP_AREAS, + WATER_RISK_MAP_PIPES_BY_AREA, + WATER_RISK_PIPE_LAYER_TYPE, + WATER_RISK_RESULT_ROWS, + WATER_RISK_SETTING_ROWS, + type WaterRiskSessionSnapshot, + type WaterRiskStagedPipe, +} from "./components/waterRisk/waterRiskTypes"; const AnalysisPopupMenu = dynamic( () => import("./components/analysisMenu/AnalysisPopupMenu"), @@ -69,30 +144,187 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; +const SINKHOLE_DEFAULT_POINT_LIMIT = 2000; +// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다. +const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png"; const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ - 720000, - 1445000, - 1410000, - 2090000, + 600000, + 1300000, + 1500000, + 2500000, ]; // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; const FLOW_DIRECTION_LAYER_NAME = "유하방향"; +const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const FLOW_DIRECTION_SRS = "EPSG:5179"; const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13; -const FLOW_DIRECTION_DEFAULT_LIMIT = 5000; const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)"; +const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)"; const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35; -const FLOW_DIRECTION_CACHE_MAX_FEATURES = 10000; +const GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO = 0.15; +const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW"; +const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; +const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA"; +const WATER_RISK_GRID_LAYER_ID = "SN_WATER_RISK_GRID"; +const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE"; +const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [100, 380, 120, 120]; +const RIGHT_PANEL_TAB_LIMIT = 5; +// Only pipes opened from sinkhole details share one slot within the parent-return flow. +const RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID = "feature:tb_ms_swage_ppl:sinkhole-singleton"; + +type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit"; +type RightPanelTabCategory = + | "sinkhole" + | "pipe" + | "treatmentFacility" + | "treatmentZone" + | "defaultFeature" + | "gisEdit"; + +type RightPanelTab = { + id: string; + kind: RightPanelTabKind; + category: RightPanelTabCategory; + displayLabel: string; + parentTabId?: string | null; + featureData: any; + sinkholeDetail?: SinkholeDetail | null; + nearbyPipes?: SinkholeNearbyPipe[]; + gisEditChangeId?: string | null; + lastActiveAt: number; +}; + +type RightPanelTabStyle = { + active: string; + inactive: string; +}; + +type RightPanelBookmarkTabsProps = { + tabs: RightPanelTab[]; + activeTabId: string | null; + onActivate: (tabId: string) => void; + onBack: (tabId: string) => void; +}; + +// 유형별 의미가 색상과 탭 이름에 함께 드러나도록 정적 class를 사용한다. +const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record< + RightPanelTabCategory, + RightPanelTabStyle +> = { + sinkhole: { + active: "border-[#d97706] bg-[#d97706] text-white", + inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]", + }, + pipe: { + active: "border-[#2563eb] bg-[#2563eb] text-white", + inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]", + }, + treatmentFacility: { + active: "border-[#15803d] bg-[#15803d] text-white", + inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]", + }, + treatmentZone: { + active: "border-[#7e22ce] bg-[#7e22ce] text-white", + inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]", + }, + gisEdit: { + active: "border-[#0f766e] bg-[#0f766e] text-white", + inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]", + }, + defaultFeature: { + active: "border-[#475569] bg-[#475569] text-white", + inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]", + }, +}; + +// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다. +const findRightPanelTabOverflowCandidate = ( + tabs: RightPanelTab[], + nextTabId: string, +) => { + return tabs.find((tab) => tab.id !== nextTabId) ?? null; +}; + +// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다. +const removeRightPanelTabGroup = (tabs: RightPanelTab[], removedTabId: string) => + tabs.filter( + (tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId, + ); + +// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다. +const RightPanelBookmarkTabs = ({ + tabs, + activeTabId, + onActivate, + onBack, +}: RightPanelBookmarkTabsProps) => { + if (tabs.length < 2) return null; + + return ( +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const style = RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category]; + const parentTab = tab.parentTabId + ? tabs.find((candidate) => candidate.id === tab.parentTabId) + : null; + + return ( +
+ {tab.category === "pipe" && parentTab ? ( + + ) : null} + +
+ ); + })} +
+ ); +}; type FlowDirectionUsageStyle = { color: string; lineColor: string; }; +type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH"; + +// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다. +const getFlowDirectionModeByPathname = (pathname: string | null): FlowDirectionMode => { + const normalizedPathname = String(pathname ?? "").replace(/\/$/, ""); + if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END"; + return "PIPE_DEPTH"; +}; + const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120]; const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50]; +const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt"; + +type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none"; // 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다. const getLayerTypeName = (layerInfo: any) => @@ -102,6 +334,45 @@ const getLayerTypeName = (layerInfo: any) => layerInfo?.lyr_type_nm ?? "", ).trim(); +const getLayerFilterSql = (layerInfo: any) => + String( + layerInfo?.filtrSql ?? + layerInfo?.filtr_sql ?? + layerInfo?.flterSql ?? + layerInfo?.filterSql ?? + layerInfo?.filter_sql ?? + "", + ) + .replace(/\s+/g, "") + .toLowerCase(); + +const getSwagePrcsFcltCapacityKind = (layerInfo: any): Exclude | null => { + if (getLayerTypeName(layerInfo) !== SWAGE_PRCS_FCLT_LAYER_TYPE) return null; + + const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").replace(/\s+/g, ""); + const filterSql = getLayerFilterSql(layerInfo); + + if (filterSql.includes("fclt_cpct>=500") || layerName.includes("500이상") || layerName.includes("500톤이상")) return "gte500"; + if (filterSql.includes("fclt_cpct<500") || layerName.includes("500미만") || layerName.includes("500톤미만")) return "lt500"; + + return null; +}; + + +const getSwagePrcsFcltLayerMode = (checkedNodesData: any[]): SwagePrcsFcltLayerMode => { + const checkedKinds = new Set( + checkedNodesData + .map((node) => getSwagePrcsFcltCapacityKind(node?.data ?? node)) + .filter(Boolean), + ); + const hasGte500 = checkedKinds.has("gte500"); + const hasLt500 = checkedKinds.has("lt500"); + + if (hasGte500 && hasLt500) return "all"; + if (hasGte500) return "gte500"; + if (hasLt500) return "lt500"; + return "none"; +}; // 유하방향 노드는 타입명 또는 표시명으로 식별한다. DB DML 반영 전후 응답 차이를 모두 흡수하기 위한 판별 함수이다. const isFlowDirectionLayerInfo = (layerInfo: any) => { @@ -141,15 +412,19 @@ const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => { }; // 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. +const getFiniteNumber = (value: unknown) => { + if (value === null || value === undefined || String(value).trim() === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + const getFlowDirectionMinZoom = (layerInfo: any) => { - const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); - return Number.isFinite(minZoom) ? minZoom : FLOW_DIRECTION_DEFAULT_MIN_ZOOM; + return getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ?? FLOW_DIRECTION_DEFAULT_MIN_ZOOM; }; // 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다. const getFlowDirectionZIndex = (layerInfo: any) => { - const drawOrder = Number(layerInfo?.drawOrdr); - return Number.isFinite(drawOrder) ? drawOrder : 9998; + return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998; }; // BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다. @@ -159,6 +434,58 @@ const getFeatureCollection = (payload: any) => { return { type: "FeatureCollection", features: [] }; }; +// 지반침하 목록 API가 정상일 때는 x/y를 바로 Point feature로 바꿔 geometry 전용 API 장애를 우회할 수 있다. +const buildSinkholeFeatureCollectionFromItems = (items: SinkholeListItem[]) => ({ + type: "FeatureCollection", + features: items + .map((item) => { + const x = Number(item?.x); + const y = Number(item?.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return null; + } + + return { + type: "Feature", + id: `${SINKHOLE_LAYER_TYPE}.${String(item?.acdntNo ?? "").trim()}`, + geometry: { + type: "Point", + coordinates: [x, y], + }, + properties: { + ...item, + lyrTypeNm: SINKHOLE_LAYER_TYPE, + x, + y, + }, + }; + }) + .filter(Boolean), +}); + +const SINKHOLE_POINT_PALETTE = { + orange: { + fill: "rgba(214, 102, 31, 0.88)", + fillSelected: "rgba(255, 138, 0, 0.92)", + stroke: "rgba(255, 255, 255, 0.95)", + strokeSelected: "rgba(120, 53, 15, 0.98)", + radiusStroke: "rgba(214, 102, 31, 0.75)", + radiusFill: "rgba(251, 146, 60, 0.12)", + }, + blue: { + fill: "rgba(37, 99, 235, 0.82)", + fillSelected: "rgba(37, 99, 235, 0.9)", + stroke: "rgba(255, 255, 255, 0.95)", + strokeSelected: "rgba(30, 64, 175, 0.98)", + radiusStroke: "rgba(37, 99, 235, 0.7)", + radiusFill: "rgba(96, 165, 250, 0.12)", + }, +} as const; + +type SinkholePointVariant = keyof typeof SINKHOLE_POINT_PALETTE; + +const resolveSinkholePointVariant = (_ocrnYmd?: string | null): SinkholePointVariant => "blue"; + // 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다. const expandFlowDirectionExtent = (extent: number[]) => { const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; @@ -186,6 +513,1578 @@ const getFlowDirectionFeatureCacheKey = (feature: any) => { return `geom:${String(usageCode).trim()}:${geometryKey}`; }; +const getGeometryExtentCenterPoint = (geometry: any) => { + const extent = geometry?.getExtent?.(); + if (!extent || extent.length < 4) return null; + + const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; + return new Point([(minX + maxX) / 2, (minY + maxY) / 2]); +}; + +// MultiPolygon/MultiLineString preview 라벨이 각 조각마다 반복되지 않도록 대표 지점 1곳에만 텍스트를 배치한다. +const getGisEditPreviewLabelGeometry = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "Point") { + return geometry; + } + + if (geometryType === "Polygon") { + return geometry.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(geometry); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getPolygons?.() ?? []; + if (polygons.length > 0) { + const representativePolygon = polygons.reduce((largest: any, current: any) => + (current?.getArea?.() ?? 0) > (largest?.getArea?.() ?? 0) ? current : largest, + polygons[0]); + return representativePolygon?.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(representativePolygon); + } + return getGeometryExtentCenterPoint(geometry); + } + + return getGeometryExtentCenterPoint(geometry); +}; + +const createGisEditPreviewStyle = (feature: any) => { + const editType = String(feature.get("editType") ?? ""); + const geometry = feature.getGeometry?.(); + const geometryType = geometry?.getType?.(); + const previewLabel = String(feature.get("previewLabel") ?? "").trim(); + const isInteractionTarget = Boolean(feature.get("isInteractionTarget")); + const isDragging = Boolean(feature.get("isDragging")); + const layerPointColor = String(feature.get("layerPointColor") ?? "").trim(); + const layerLineColor = String(feature.get("layerLineColor") ?? "").trim(); + const layerFillColor = String(feature.get("layerFillColor") ?? "").trim(); + const pointColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.85)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.92)" + : editType === "MOVE" + ? layerPointColor || "rgba(37, 99, 235, 0.92)" + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerPointColor || "rgba(22, 163, 74, 0.92)"; + const lineColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.9)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.95)" + : editType === "MOVE" + ? layerLineColor || "rgba(37, 99, 235, 0.95)" + : editType === "ADD" + ? "rgba(15, 23, 42, 0.96)" + : layerLineColor || "rgba(22, 163, 74, 0.95)"; + const fillColor = editType === "DELETE" + ? "rgba(220, 38, 38, 0.18)" + : editType === "VERTEX_EDIT" + ? "rgba(245, 158, 11, 0.2)" + : editType === "MOVE" + ? layerFillColor || "rgba(37, 99, 235, 0.18)" + : editType === "ADD" + ? "rgba(15, 23, 42, 0.2)" + : layerFillColor || "rgba(22, 163, 74, 0.18)"; + const highlightColor = isDragging + ? "rgba(14, 116, 144, 0.98)" + : isInteractionTarget + ? "rgba(15, 23, 42, 0.9)" + : lineColor; + const emphasisWidth = isDragging ? 7 : isInteractionTarget ? 6 : editType === "ADD" ? 6 : 4; + const textStyle = previewLabel + ? new Text({ + text: previewLabel, + font: "600 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: highlightColor }), + padding: [3, 6, 3, 6], + offsetY: geometryType === "Point" ? -18 : -12, + overflow: true, + }) + : undefined; + const labelGeometry = previewLabel ? getGisEditPreviewLabelGeometry(geometry) : null; + + if (geometryType === "Point") { + return new Style({ + image: new CircleStyle({ + radius: isInteractionTarget ? 10 : 8, + fill: new Fill({ color: pointColor }), + stroke: new Stroke({ + color: isDragging ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.95)", + width: isInteractionTarget ? 3 : 2, + }), + }), + text: textStyle, + zIndex: 10001, + }); + } + + if (geometryType === "Polygon") { + const polygonStyles: Style[] = [ + new Style({ + fill: new Fill({ + color: isInteractionTarget && editType !== "DELETE" + ? fillColor.replace(/0\.\d+\)$/, "0.28)") + : fillColor, + }), + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [8, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + polygonStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return polygonStyles; + } + + const lineStyles: Style[] = editType === "ADD" + ? [ + new Style({ + stroke: new Stroke({ + color: "rgba(255, 255, 255, 0.98)", + width: emphasisWidth + 4, + }), + zIndex: 10000, + }), + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + }), + zIndex: 10003, + }), + ] + : [ + new Style({ + stroke: new Stroke({ + color: highlightColor, + width: emphasisWidth, + lineDash: editType === "DELETE" ? [10, 6] : undefined, + }), + zIndex: 10001, + }), + ]; + + if (textStyle && labelGeometry) { + lineStyles.push( + new Style({ + geometry: labelGeometry, + text: textStyle, + zIndex: 10002, + }), + ); + } + + return lineStyles; +}; + +const GIS_EDIT_VERTEX_HANDLE_STYLE = new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color: "rgba(245, 158, 11, 0.98)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10005, +}); + +const GIS_EDIT_VERTEX_MIDPOINT_STYLE = new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ color: "rgba(255, 255, 255, 0.98)" }), + stroke: new Stroke({ color: "rgba(245, 158, 11, 0.98)", width: 2.5 }), + }), + zIndex: 10004, +}); + +const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({ + image: new CircleStyle({ + radius: 11, + fill: new Fill({ color: "rgba(59, 130, 246, 0.95)" }), + stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }), + }), + zIndex: 10006, +}); + +const GIS_EDIT_VERTEX_SELECTED_STYLE = new Style({ + image: new CircleStyle({ + radius: 11, + fill: new Fill({ color: "rgba(217, 119, 6, 0.98)" }), + stroke: new Stroke({ color: "rgba(15, 23, 42, 0.95)", width: 3 }), + }), + zIndex: 10007, +}); + +const getGisEditVertexHandleStyle = (kind: VertexHandleMeta["kind"], isSelected = false) => { + if (isSelected && kind === "vertex") { + return GIS_EDIT_VERTEX_SELECTED_STYLE; + } + return kind === "midpoint" ? GIS_EDIT_VERTEX_MIDPOINT_STYLE : GIS_EDIT_VERTEX_HANDLE_STYLE; +}; + +const expandGisEditPipeSnapExtent = (extent: number[]) => { + const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent; + const width = Math.max(0, maxX - minX); + const height = Math.max(0, maxY - minY); + const bufferX = width * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO; + const bufferY = height * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO; + return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY]; +}; + +const geometryObjectToKey = (geometry: Record | null) => + geometry ? JSON.stringify(geometry) : ""; + +const getWaterRiskAreaMapData = (areaId: string | null) => + WATER_RISK_MAP_AREAS.find((area) => area.areaId === areaId) ?? null; + +const getWaterRiskResultRow = (areaId: string | null) => + WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId) ?? null; + +const buildWaterRiskInfoAnchor = (areaId: string | null) => { + const areaData = getWaterRiskAreaMapData(areaId); + if (!areaData) return null; + + const polygon = new Polygon(areaData.polygonCoordinates); + const extent = polygon.getExtent(); + const [, minY = 0, maxX = 0, maxY = 0] = extent; + return [maxX, (minY + maxY) / 2] as [number, number]; +}; + +const buildWaterRiskSeedPipes = (areaId: string) => + WATER_RISK_SETTING_ROWS + .filter((row) => row.areaId === areaId) + .sort((a, b) => a.setSeq - b.setSeq) + .map((row) => ({ + pipeId: row.pipeId, + pipeDia: row.pipeDia, + pipeLen: row.pipeLen, + pipeMaterial: row.pipeMaterial, + setSeq: row.setSeq, + source: "MOCK", + })); + +const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") => + riskStatus === "DANGER" ? "rgba(198, 40, 40, 0.24)" : "rgba(21, 101, 192, 0.2)"; + +const buildWaterRiskStatusStrokeColor = (riskStatus: "DANGER" | "NORMAL") => + riskStatus === "DANGER" ? "rgba(198, 40, 40, 0.92)" : "rgba(21, 101, 192, 0.92)"; + +const buildWaterRiskGridFillColor = (colorLevel: string) => { + switch (colorLevel) { + case "LEVEL_4": + return "rgba(198, 40, 40, 0.28)"; + case "LEVEL_3": + return "rgba(245, 124, 0, 0.24)"; + case "LEVEL_2": + return "rgba(251, 192, 45, 0.22)"; + default: + return "rgba(33, 150, 243, 0.18)"; + } +}; + +const createWaterRiskAreaStyle = (feature: any) => { + const areaName = String(feature.get("areaName") ?? ""); + const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as "DANGER" | "NORMAL"; + return new Style({ + fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }), + stroke: new Stroke({ + color: buildWaterRiskStatusStrokeColor(riskStatus), + width: 3, + }), + text: new Text({ + text: `${areaName}\n${buildWaterRiskStatusLabel(riskStatus)}`, + font: "700 13px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: buildWaterRiskStatusStrokeColor(riskStatus) }), + padding: [5, 8, 5, 8], + overflow: true, + }), + zIndex: 9996, + }); +}; + +const createWaterRiskGridStyle = (feature: any) => { + const rainQty = Number(feature.get("rainQty") ?? 0); + const colorLevel = String(feature.get("colorLevel") ?? "LEVEL_1"); + return new Style({ + fill: new Fill({ color: buildWaterRiskGridFillColor(colorLevel) }), + stroke: new Stroke({ + color: "rgba(71, 85, 105, 0.36)", + width: 1, + }), + text: new Text({ + text: `${rainQty.toFixed(1)}mm`, + font: "600 11px sans-serif", + fill: new Fill({ color: "#0f172a" }), + overflow: true, + }), + zIndex: 9995, + }); +}; + +const createWaterRiskPipeStyle = (feature: any) => { + const displayLabel = String(feature.get("displayLabel") ?? ""); + return [ + new Style({ + stroke: new Stroke({ + color: "rgba(255,255,255,0.98)", + width: 9, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 9997, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(205, 52, 52, 0.96)", + width: 4, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 9998, + text: new Text({ + text: displayLabel, + font: "700 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: "rgba(60, 60, 60, 0.9)" }), + padding: [3, 6, 3, 6], + overflow: true, + }), + }), + ]; +}; + +const getSelectedFeatureLayerTypeName = (data: any) => { + const previewLayerId = String(data?.get?.("layerId") ?? "").trim(); + if (String(data?.get?.("changeId") ?? "").trim() && previewLayerId) { + return previewLayerId; + } + + if (typeof data?.getId === "function") { + return String(data.getId() ?? "").split(".")[0]; + } + + if (typeof data?.id_ === "string") { + return String(data.id_).split(".")[0]; + } + + return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim(); +}; + +const getRightPanelFeatureIdentifier = (data: any) => { + const rawId = + data?.get?.("changeId") ?? + data?.get?.("fid") ?? + data?.values_?.fid ?? + data?.fid ?? + data?.getId?.() ?? + data?.id_ ?? + data?.fcltMngNo ?? + data?.values_?.fclt_mng_no ?? + data?.swtrmtFcltCd ?? + data?.values_?.swtrmt_fclt_cd ?? + ""; + + return String(rawId).trim(); +}; + +const getRightPanelFeatureTitle = (data: any) => { + const layerTypeName = getSelectedFeatureLayerTypeName(data); + + switch (layerTypeName) { + case "tb_ms_swage_ppl": + return "하수관로"; + case "tb_ms_swage_mhl": + return "하수맨홀"; + case "tb_ms_swage_pmstn": + return "펌프장"; + case "tb_ms_stofcbr": + return "우수토실"; + case "tb_il_swage_prcs_fclt": + return "처리시설"; + case "tb_ms_swage_prcs_zone": + return "처리구역"; + case "om_dt_untrt_swg_rain": + return "중점우수"; + default: + return String(data?.lyrNm ?? data?.values_?.lyrNm ?? "상세").trim() || "상세"; + } +}; + +// OpenLayers Feature와 plain object 양쪽에서 탭 표시값 후보를 같은 순서로 읽는다. +const getRightPanelFeatureProperty = (data: any, keys: string[]) => { + for (const key of keys) { + const value = data?.get?.(key) ?? data?.values_?.[key] ?? data?.[key]; + const normalizedValue = String(value ?? "").trim(); + if (normalizedValue) return normalizedValue; + } + return ""; +}; + +const getRightPanelTabCategory = (data: any): RightPanelTabCategory => { + const layerTypeName = getSelectedFeatureLayerTypeName(data); + if (layerTypeName === "tb_ms_swage_ppl") return "pipe"; + if (layerTypeName === "tb_il_swage_prcs_fclt") return "treatmentFacility"; + if (layerTypeName === "tb_ms_swage_prcs_zone") return "treatmentZone"; + return "defaultFeature"; +}; + +const getRightPanelFeatureDisplayLabel = (data: any, category: RightPanelTabCategory) => { + if (category === "pipe") { + const managementNo = + getRightPanelFeatureProperty(data, ["fclt_mng_no", "fcltMngNo", "pipeId", "fid"]) || + getRightPanelFeatureIdentifier(data) || + "-"; + return `관로(${managementNo})`; + } + + if (category === "treatmentFacility") { + const facilityName = + getRightPanelFeatureProperty(data, ["swtrmt_fclt_nm", "swtrmtFcltNm"]) || + getRightPanelFeatureProperty(data, ["swtrmt_fclt_cd", "swtrmtFcltCd"]) || + "-"; + return `처리시설(${facilityName})`; + } + + if (category === "treatmentZone") { + const zoneName = + getRightPanelFeatureProperty(data, ["swtrmt_zone_nm", "swtrmtZoneNm"]) || + getRightPanelFeatureProperty(data, [ + "swtrmt_zone_sn", + "swtrmtZoneSn", + "swtrmt_fclt_cd", + "swtrmtFcltCd", + ]) || + "-"; + return `처리구역(${zoneName})`; + } + + return getRightPanelFeatureTitle(data); +}; + +const createFloodHistoryAreaStyle = (label: string) => + new Style({ + fill: new Fill({ + color: "rgba(30, 136, 229, 0.16)", + }), + stroke: new Stroke({ + color: "rgba(30, 136, 229, 0.95)", + width: 3, + }), + text: new Text({ + text: label, + font: "700 12px sans-serif", + fill: new Fill({ color: "#ffffff" }), + backgroundFill: new Fill({ color: "rgba(15, 23, 42, 0.88)" }), + padding: [4, 7, 4, 7], + overflow: true, + }), + zIndex: 9994, + }); + +const createFloodHistoryDepthStyle = (label: string, color: string) => + new Style({ + fill: new Fill({ + color, + }), + stroke: new Stroke({ + color: "rgba(71, 85, 105, 0.72)", + width: 1.4, + }), + text: new Text({ + text: label, + font: "700 11px sans-serif", + fill: new Fill({ color: "#0f172a" }), + overflow: true, + }), + zIndex: 9993, + }); + +const normalizeGisEditFid = (value: unknown) => { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw; + return withoutNamespace.startsWith("tb_ms_swage_ppl.") + ? withoutNamespace.slice("tb_ms_swage_ppl.".length) + : withoutNamespace; +}; + +const getGisEditFeatureFid = (data: any) => + normalizeGisEditFid( + data?.get?.("fid") ?? + data?.values_?.fid ?? + data?.fid ?? + data?.getId?.() ?? + data?.id_ ?? + "", + ); + +const isGisEditPreviewFeatureData = (data: any) => + Boolean(String(data?.get?.("changeId") ?? data?.changeId ?? "").trim()); + +const highlightGisEditSelectedFeature = (featureData: any) => { + if ( + !featureData || + typeof featureData.getGeometry !== "function" || + typeof featureData.setStyle !== "function" + ) { + return; + } + + const select = GisApp.Module.select as any; + select?.selectFeature?.(featureData, true); +}; + +const isVisibleGisEditSessionChange = (change: GisEditSessionSnapshot["changes"][number]) => + !change.attributeOnly || Boolean(change.basicInfoSavedAt); + +const getLatestNumericEditSn = (histories: any[]) => + histories.reduce((latest, history) => { + const editSn = String(history?.editSn ?? "").trim(); + if (!/^\d+$/.test(editSn)) return latest; + const value = Number(editSn); + return latest === null || value > latest ? value : latest; + }, null); + +const getGisEditAttributeText = (attributes: Record | null | undefined, keys: string[]) => { + for (const key of keys) { + const value = String(attributes?.[key] ?? "").trim(); + if (value) return value; + } + return ""; +}; + +const buildGisEditHiddenPipeFilter = (fids: string[]) => { + const normalizedFids = Array.from( + new Set( + fids + .map((fid) => normalizeGisEditFid(fid)) + .filter((fid) => /^\d+$/.test(fid)), + ), + ); + if (normalizedFids.length === 0) return null; + return `fid NOT IN (${normalizedFids.join(",")})`; +}; + +const findInvalidPipeRequiredChange = (changes: Array<{ changeId: string; editType: string; clientTempId?: string; attributes?: Record | null }>) => + changes.find((change) => { + const attributes = change.attributes ?? {}; + const hasAttributePayload = Object.keys(attributes).length > 0; + if (change.editType !== "ADD" && !hasAttributePayload) return false; + + const mngInstCd = getGisEditAttributeText(attributes, ["mngInstCd"]); + const fcltMngNo = getGisEditAttributeText(attributes, ["fcltMngNo"]); + const instMngNo = getGisEditAttributeText(attributes, ["instMngNo"]); + const usgCd = getGisEditAttributeText(attributes, ["usgCd"]); + return !mngInstCd || !fcltMngNo || /^NEW-/i.test(fcltMngNo) || !instMngNo || /^NEW-/i.test(instMngNo) || !usgCd; + }); + +const getSinkholeSelectionValue = (data: any, key: string) => + data?.[key] ?? data?.values_?.[key] ?? data?.get?.(key) ?? data?.properties?.[key]; + +const getSinkholeAccidentId = (data: any) => + String(getSinkholeSelectionValue(data, "acdntNo") ?? "").trim(); + +const getSinkholeFallbackDetailFromSelection = (data: any): Partial | null => { + const acdntNo = getSinkholeAccidentId(data); + if (!acdntNo) return null; + + const point = getSinkholePointFromSelection(data); + return { + acdntNo, + ctpvNm: String(getSinkholeSelectionValue(data, "ctpvNm") ?? ""), + sggNm: String(getSinkholeSelectionValue(data, "sggNm") ?? ""), + emdNm: String(getSinkholeSelectionValue(data, "emdNm") ?? ""), + daddr: String(getSinkholeSelectionValue(data, "daddr") ?? ""), + fullAddress: String(getSinkholeSelectionValue(data, "fullAddress") ?? getSinkholeSelectionValue(data, "daddr") ?? ""), + ocrnYmd: String(getSinkholeSelectionValue(data, "ocrnYmd") ?? ""), + restoreStatus: String(getSinkholeSelectionValue(data, "restoreStatus") ?? ""), + restoreCompletedYmd: String(getSinkholeSelectionValue(data, "restoreCompletedYmd") ?? ""), + coordStatus: String(getSinkholeSelectionValue(data, "coordStatus") ?? getSinkholeSelectionValue(data, "geocodeStatus") ?? ""), + geocodeStatus: String(getSinkholeSelectionValue(data, "geocodeStatus") ?? getSinkholeSelectionValue(data, "coordStatus") ?? ""), + geocodeSource: String(getSinkholeSelectionValue(data, "geocodeSource") ?? ""), + geocodeErrorMessage: String(getSinkholeSelectionValue(data, "geocodeErrorMessage") ?? ""), + latitude: getSinkholeSelectionValue(data, "latitude") ?? null, + longitude: getSinkholeSelectionValue(data, "longitude") ?? null, + x: point?.x ?? getSinkholeSelectionValue(data, "x") ?? null, + y: point?.y ?? getSinkholeSelectionValue(data, "y") ?? null, + sizeWidth: getSinkholeSelectionValue(data, "sizeWidth") ?? null, + sizeLength: getSinkholeSelectionValue(data, "sizeLength") ?? null, + sizeDepth: getSinkholeSelectionValue(data, "sizeDepth") ?? null, + detailCause: getSinkholeSelectionValue(data, "detailCause") ?? null, + deathCount: getSinkholeSelectionValue(data, "deathCount") ?? null, + injuryCount: getSinkholeSelectionValue(data, "injuryCount") ?? null, + vehicleDamageCount: getSinkholeSelectionValue(data, "vehicleDamageCount") ?? null, + }; +}; + +const getSinkholePointFromSelection = (data: any): { x: number; y: number } | null => { + const directX = Number( + getSinkholeSelectionValue(data, "x"), + ); + const directY = Number( + getSinkholeSelectionValue(data, "y"), + ); + if (Number.isFinite(directX) && Number.isFinite(directY)) { + return { x: directX, y: directY }; + } + + const geometry = data?.getGeometry?.(); + if (geometry?.getType?.() === "Point") { + const coordinates = geometry.getCoordinates?.(); + const x = Number(coordinates?.[0]); + const y = Number(coordinates?.[1]); + if (Number.isFinite(x) && Number.isFinite(y)) { + return { x, y }; + } + } + + return null; +}; + +const createSinkholePointStyle = ( + variant: SinkholePointVariant = "blue", + selected = false, +) => + new Style({ + image: new CircleStyle({ + radius: selected ? 9 : 7, + fill: new Fill({ + color: selected + ? SINKHOLE_POINT_PALETTE[variant].fillSelected + : SINKHOLE_POINT_PALETTE[variant].fill, + }), + stroke: new Stroke({ + color: selected + ? SINKHOLE_POINT_PALETTE[variant].strokeSelected + : SINKHOLE_POINT_PALETTE[variant].stroke, + width: selected ? 2.6 : 2, + }), + }), + zIndex: selected ? 9997 : 9994, + }); + +const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "blue") => + new Style({ + stroke: new Stroke({ + color: SINKHOLE_POINT_PALETTE[variant].radiusStroke, + width: 2.2, + lineDash: [8, 6], + }), + fill: new Fill({ + color: SINKHOLE_POINT_PALETTE[variant].radiusFill, + }), + zIndex: 9993, + }); +// 반경 원 상단에 실제 적용 거리값을 표시하는 선택 전용 지도 라벨 스타일이다. +const createSinkholeRadiusLabelStyle = ( + radiusMeters: number, + variant: SinkholePointVariant = "blue", +) => + new Style({ + text: new Text({ + text: `반경 ${radiusMeters.toLocaleString("ko-KR")}m`, + font: "600 13px Pretendard, sans-serif", + textAlign: "center", + offsetY: -14, + padding: [4, 7, 4, 7], + fill: new Fill({ color: SINKHOLE_POINT_PALETTE[variant].strokeSelected }), + stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 3 }), + backgroundFill: new Fill({ color: "rgba(255, 255, 255, 0.9)" }), + backgroundStroke: new Stroke({ color: SINKHOLE_POINT_PALETTE[variant].radiusStroke, width: 1.5 }), + }), + zIndex: 9998, + }); + + +const createSinkholeFocusedPipeStyle = () => [ + new Style({ + stroke: new Stroke({ + color: "rgba(88, 28, 135, 0.22)", + width: 18, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 9997, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(255, 255, 255, 0.96)", + width: 12, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 9998, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(37, 99, 235, 0.98)", + width: 8, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 9999, + }), +]; + +const formatWaterRiskPipeField = (value: unknown, suffix: string) => { + const normalized = String(value ?? "").trim(); + if (!normalized) return "-"; + if (normalized.endsWith(suffix) || suffix === "") return normalized; + return `${normalized} ${suffix}`.trim(); +}; + +const buildWaterRiskPipeFromFeature = (data: any): WaterRiskStagedPipe | null => { + const values = data?.values_ ?? data; + const pipeId = String( + values?.fclt_mng_no ?? + values?.fcltMngNo ?? + values?.pipeId ?? + values?.id ?? + data?.fid ?? + "", + ).trim(); + + if (!pipeId) return null; + + return { + pipeId, + pipeDia: formatWaterRiskPipeField( + values?.pipe_diameter ?? values?.pdmt_vl ?? values?.pdmtVl, + "mm", + ), + pipeLen: formatWaterRiskPipeField( + values?.pipe_lt ?? values?.pipeLt ?? values?.extn_lt ?? values?.extnLt, + "m", + ), + pipeMaterial: String( + values?.pipe_material ?? + values?.pipeMaterial ?? + values?.mqlt_cd ?? + values?.mqltCd ?? + "-", + ).trim() || "-", + setSeq: 0, + source: "MAP", + }; +}; + +type VertexHandleMeta = { + kind: "vertex" | "midpoint"; + geometryType: string; + partIndex?: number; + polygonIndex?: number; + ringIndex?: number; + coordIndex: number; +}; + +type VertexHandleCandidate = { + coordinate: number[]; + meta: VertexHandleMeta; +}; + +type VertexDeleteResult = { + ok: boolean; + message?: string; +}; + +const cloneCoordinates = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const getMidpoint = (start: number[], end: number[]) => [ + (Number(start?.[0] ?? 0) + Number(end?.[0] ?? 0)) / 2, + (Number(start?.[1] ?? 0) + Number(end?.[1] ?? 0)) / 2, +]; + +const buildVertexCoordinateKey = (coordinate: number[]) => + `${Number(coordinate?.[0] ?? 0)}:${Number(coordinate?.[1] ?? 0)}`; + +const buildVertexHandleMetaKey = (meta: VertexHandleMeta) => + [ + meta.kind, + meta.geometryType, + meta.partIndex ?? -1, + meta.polygonIndex ?? -1, + meta.ringIndex ?? -1, + meta.coordIndex, + ].join(":"); + +const buildVertexHandleSelectionKey = (metaList: VertexHandleMeta[]) => + metaList + .map(buildVertexHandleMetaKey) + .sort() + .join("|"); + +const getSquaredPixelDistance = ( + map: any, + startCoordinate: number[], + endCoordinate: number[], +) => { + const [startX = 0, startY = 0] = map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + return (endX - startX) ** 2 + (endY - startY) ** 2; +}; + +const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12; + +const getAverageCoordinate = (coordinates: number[][]) => { + if (coordinates.length === 0) return [0, 0]; + + const [sumX, sumY] = coordinates.reduce<[number, number]>( + ([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)], + [0, 0], + ); + + return [sumX / coordinates.length, sumY / coordinates.length]; +}; + +const getPixelDistanceToSegment = ( + map: any, + coordinate: number[], + startCoordinate: number[], + endCoordinate: number[], +) => { + const [pointX = 0, pointY = 0] = map?.getPixelFromCoordinate?.(coordinate) ?? []; + const [startX = 0, startY = 0] = map?.getPixelFromCoordinate?.(startCoordinate) ?? []; + const [endX = 0, endY = 0] = map?.getPixelFromCoordinate?.(endCoordinate) ?? []; + const deltaX = endX - startX; + const deltaY = endY - startY; + const segmentLengthSquared = deltaX ** 2 + deltaY ** 2; + + if (segmentLengthSquared === 0) { + return Math.hypot(pointX - startX, pointY - startY); + } + + const ratio = Math.max( + 0, + Math.min( + 1, + ((pointX - startX) * deltaX + (pointY - startY) * deltaY) / segmentLengthSquared, + ), + ); + const projectedX = startX + deltaX * ratio; + const projectedY = startY + deltaY * ratio; + return Math.hypot(pointX - projectedX, pointY - projectedY); +}; + +const createVertexHandleFeature = ( + coordinate: number[], + meta: VertexHandleMeta, + metaList: VertexHandleMeta[] = [meta], +) => { + const handleFeature = new Feature({ + geometry: new Point(coordinate), + }); + handleFeature.set("handleKind", meta.kind); + handleFeature.set("handleMeta", meta); + handleFeature.set("handleMetaList", metaList); + handleFeature.set("handleSelectionKey", buildVertexHandleSelectionKey(metaList)); + handleFeature.setStyle(getGisEditVertexHandleStyle(meta.kind)); + return handleFeature; +}; + +const collectLineVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + coordinates.forEach((coordinate, coordIndex) => { + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + partIndex, + coordIndex, + }, + }); + }); + return candidates; +}; + +const collectPolygonRingVertexHandleCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const coordinate = coordinates[coordIndex]; + const nextCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount] ?? coordinate; + if (!coordinate || !nextCoordinate) continue; + candidates.push({ + coordinate, + meta: { + kind: "vertex", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectVertexHandleCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineVertexHandleCandidates(geometry.getCoordinates?.() ?? [], geometryType); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineVertexHandleCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates(ring, geometryType, undefined, ringIndex), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingVertexHandleCandidates(ring, geometryType, polygonIndex, ringIndex), + ), + ); + } + + return []; +}; + +const buildVertexHandleFeatures = (geometry: any, map?: any): Feature[] => { + const groupedCandidates: Array<{ + coordinate: number[]; + metaList: VertexHandleMeta[]; + sourceCoordinates: number[][]; + }> = []; + + collectVertexHandleCandidates(geometry).forEach((candidate: VertexHandleCandidate) => { + const existing = groupedCandidates.find((group) => { + if (!map) { + return buildVertexCoordinateKey(group.coordinate) === buildVertexCoordinateKey(candidate.coordinate); + } + + return ( + getSquaredPixelDistance(map, group.coordinate, candidate.coordinate) <= + GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2 + ); + }); + + if (existing) { + existing.metaList.push(candidate.meta); + existing.sourceCoordinates.push(candidate.coordinate); + existing.coordinate = getAverageCoordinate(existing.sourceCoordinates); + return; + } + + groupedCandidates.push({ + coordinate: candidate.coordinate, + metaList: [candidate.meta], + sourceCoordinates: [candidate.coordinate], + }); + }); + + return groupedCandidates.map(({ coordinate, metaList }) => + createVertexHandleFeature(coordinate, metaList[0]!, metaList), + ); +}; + +const collectLineMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + partIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + for (let coordIndex = 0; coordIndex < coordinates.length - 1; coordIndex += 1) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[coordIndex + 1]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + partIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectPolygonRingMidpointCandidates = ( + coordinates: number[][], + geometryType: string, + polygonIndex?: number, + ringIndex?: number, +) => { + const candidates: VertexHandleCandidate[] = []; + const logicalVertexCount = Math.max(0, coordinates.length - 1); + for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) { + const startCoordinate = coordinates[coordIndex]; + const endCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount]; + if (!startCoordinate || !endCoordinate) continue; + candidates.push({ + coordinate: getMidpoint(startCoordinate, endCoordinate), + meta: { + kind: "midpoint", + geometryType, + polygonIndex, + ringIndex, + coordIndex, + }, + }); + } + return candidates; +}; + +const collectMidpointCandidates = (geometry: any) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return []; + + if (geometryType === "LineString") { + return collectLineMidpointCandidates(geometry.getCoordinates?.() ?? [], geometryType); + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + return parts.flatMap((part: number[][], partIndex: number) => + collectLineMidpointCandidates(part, geometryType, partIndex), + ); + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + return rings.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates(ring, geometryType, undefined, ringIndex), + ); + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + return polygons.flatMap((polygon: number[][][], polygonIndex: number) => + polygon.flatMap((ring: number[][], ringIndex: number) => + collectPolygonRingMidpointCandidates(ring, geometryType, polygonIndex, ringIndex), + ), + ); + } + + return []; +}; + +const getNearestVertexPixelDistance = ( + map: any, + geometry: any, + coordinate: number[], +): number => { + return buildVertexHandleFeatures(geometry, map).reduce((minimumDistance: number, feature: Feature) => { + const vertexCoordinate = (feature.getGeometry?.() as any)?.getCoordinates?.(); + if (!Array.isArray(vertexCoordinate)) return minimumDistance; + return Math.min( + minimumDistance, + Math.sqrt(getSquaredPixelDistance(map, coordinate, vertexCoordinate)), + ); + }, Number.POSITIVE_INFINITY); +}; + +const getSegmentCoordinatesFromMeta = ( + geometry: any, + meta: VertexHandleMeta, +): [number[], number[]] | null => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "LineString") { + const coordinates = geometry.getCoordinates?.() ?? []; + return [coordinates[meta.coordIndex], coordinates[meta.coordIndex + 1]]; + } + + if (geometryType === "MultiLineString") { + const parts = geometry.getCoordinates?.() ?? []; + const targetPart = parts[meta.partIndex ?? 0] ?? []; + return [targetPart[meta.coordIndex], targetPart[meta.coordIndex + 1]]; + } + + if (geometryType === "Polygon") { + const rings = geometry.getCoordinates?.() ?? []; + const targetRing = rings[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [targetRing[meta.coordIndex], targetRing[(meta.coordIndex + 1) % logicalVertexCount]]; + } + + if (geometryType === "MultiPolygon") { + const polygons = geometry.getCoordinates?.() ?? []; + const targetPolygon = polygons[meta.polygonIndex ?? 0] ?? []; + const targetRing = targetPolygon[meta.ringIndex ?? 0] ?? []; + const logicalVertexCount = Math.max(0, targetRing.length - 1); + if (logicalVertexCount === 0) return null; + return [targetRing[meta.coordIndex], targetRing[(meta.coordIndex + 1) % logicalVertexCount]]; + } + + return null; +}; + +const findHoveredMidpointCandidate = ( + map: any, + geometry: any, + coordinate: number[], + tolerancePx = 18, +): VertexHandleCandidate | null => { + const nearestVertexDistance = getNearestVertexPixelDistance(map, geometry, coordinate); + if (nearestVertexDistance <= 14) { + return null; + } + + const midpointCandidates = collectMidpointCandidates(geometry); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + midpointCandidates.forEach((candidate: VertexHandleCandidate) => { + const segmentCoordinates = getSegmentCoordinatesFromMeta(geometry, candidate.meta); + if (!segmentCoordinates) return; + const [startCoordinate, endCoordinate] = segmentCoordinates; + if (!Array.isArray(startCoordinate) || !Array.isArray(endCoordinate)) return; + + const segmentDistance = getPixelDistanceToSegment( + map, + coordinate, + startCoordinate, + endCoordinate, + ); + if (segmentDistance < minimumDistance) { + minimumDistance = segmentDistance; + bestCandidate = candidate; + } + }); + + return minimumDistance <= tolerancePx ? bestCandidate : null; +}; + +const updateLineCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + } else { + nextCoordinates.splice(meta.coordIndex + 1, 0, nextCoordinate); + } + return nextCoordinates; +}; + +const updatePolygonRingCoordinates = ( + coordinates: number[][], + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const nextCoordinates = cloneCoordinates(coordinates); + const logicalVertexCount = Math.max(0, nextCoordinates.length - 1); + if (logicalVertexCount === 0) return nextCoordinates; + + if (meta.kind === "vertex") { + nextCoordinates[meta.coordIndex] = nextCoordinate; + if (meta.coordIndex === 0) { + nextCoordinates[logicalVertexCount] = nextCoordinate; + } + } else { + const insertIndex = meta.coordIndex === logicalVertexCount - 1 + ? logicalVertexCount + : meta.coordIndex + 1; + nextCoordinates.splice(insertIndex, 0, nextCoordinate); + } + + return nextCoordinates; +}; + +const applyVertexHandleMove = ( + geometry: any, + meta: VertexHandleMeta, + nextCoordinate: number[], +) => { + const geometryType = geometry?.getType?.(); + if (!geometryType) return; + + if (geometryType === "LineString") { + geometry.setCoordinates( + updateLineCoordinates(geometry.getCoordinates?.() ?? [], meta, nextCoordinate), + ); + return; + } + + if (geometryType === "MultiLineString") { + const parts = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPartIndex = meta.partIndex ?? 0; + parts[targetPartIndex] = updateLineCoordinates(parts[targetPartIndex] ?? [], meta, nextCoordinate); + geometry.setCoordinates(parts); + return; + } + + if (geometryType === "Polygon") { + const rings = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetRingIndex = meta.ringIndex ?? 0; + rings[targetRingIndex] = updatePolygonRingCoordinates(rings[targetRingIndex] ?? [], meta, nextCoordinate); + geometry.setCoordinates(rings); + return; + } + + if (geometryType === "MultiPolygon") { + const polygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const targetPolygonIndex = meta.polygonIndex ?? 0; + const targetRingIndex = meta.ringIndex ?? 0; + polygons[targetPolygonIndex][targetRingIndex] = updatePolygonRingCoordinates( + polygons[targetPolygonIndex][targetRingIndex] ?? [], + meta, + nextCoordinate, + ); + geometry.setCoordinates(polygons); + } +}; + +const deleteLineVertices = (coordinates: number[][], coordIndexes: number[]) => { + const nextCoordinates = cloneCoordinates(coordinates); + const uniqueIndexes = Array.from(new Set(coordIndexes)).sort((left, right) => right - left); + if (nextCoordinates.length - uniqueIndexes.length < 2) { + return null; + } + uniqueIndexes.forEach((coordIndex) => { + nextCoordinates.splice(coordIndex, 1); + }); + return nextCoordinates; +}; + +const deletePolygonVertices = (coordinates: number[][], coordIndexes: number[]) => { + const logicalVertexCount = Math.max(0, coordinates.length - 1); + const uniqueIndexes = Array.from(new Set(coordIndexes)).sort((left, right) => right - left); + if (logicalVertexCount - uniqueIndexes.length < 3) { + return null; + } + + const logicalCoordinates = cloneCoordinates(coordinates.slice(0, logicalVertexCount)); + uniqueIndexes.forEach((coordIndex) => { + logicalCoordinates.splice(coordIndex, 1); + }); + if (logicalCoordinates.length < 3) return null; + + return [...logicalCoordinates, cloneCoordinates(logicalCoordinates[0] ?? [])]; +}; + +const deleteSelectedVertexHandles = ( + geometry: any, + metaList: VertexHandleMeta[], +): VertexDeleteResult => { + if (!geometry || metaList.length === 0) { + return { ok: false, message: "삭제할 꼭지점 정보를 찾지 못했습니다." }; + } + + if (metaList.some((meta) => meta.kind !== "vertex")) { + return { ok: false, message: "흰색 midpoint는 삭제 대상이 아닙니다. 주황색 실꼭지점을 선택하세요." }; + } + + const geometryType = geometry?.getType?.(); + if (!geometryType) { + return { ok: false, message: "꼭지점 삭제 대상 geometry를 찾지 못했습니다." }; + } + + if (geometryType === "LineString") { + const nextCoordinates = deleteLineVertices( + geometry.getCoordinates?.() ?? [], + metaList.map((meta) => meta.coordIndex), + ); + if (!nextCoordinates) { + return { ok: false, message: "하수관로는 최소 2개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다." }; + } + geometry.setCoordinates(nextCoordinates); + return { ok: true }; + } + + if (geometryType === "MultiLineString") { + const nextParts = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const metaGroups = metaList.reduce>((acc, meta) => { + const partIndex = meta.partIndex ?? 0; + if (!acc[partIndex]) acc[partIndex] = []; + acc[partIndex]!.push(meta.coordIndex); + return acc; + }, {}); + + for (const [partIndexRaw, coordIndexes] of Object.entries(metaGroups)) { + const partIndex = Number(partIndexRaw); + const nextCoordinates = deleteLineVertices(nextParts[partIndex] ?? [], coordIndexes); + if (!nextCoordinates) { + return { ok: false, message: "하수관로는 각 선형마다 최소 2개의 꼭지점이 필요합니다." }; + } + nextParts[partIndex] = nextCoordinates; + } + + geometry.setCoordinates(nextParts); + return { ok: true }; + } + + if (geometryType === "Polygon") { + const nextRings = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const metaGroups = metaList.reduce>((acc, meta) => { + const ringIndex = meta.ringIndex ?? 0; + if (!acc[ringIndex]) acc[ringIndex] = []; + acc[ringIndex]!.push(meta.coordIndex); + return acc; + }, {}); + + for (const [ringIndexRaw, coordIndexes] of Object.entries(metaGroups)) { + const ringIndex = Number(ringIndexRaw); + const nextCoordinates = deletePolygonVertices(nextRings[ringIndex] ?? [], coordIndexes); + if (!nextCoordinates) { + return { ok: false, message: "폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다." }; + } + nextRings[ringIndex] = nextCoordinates; + } + + geometry.setCoordinates(nextRings); + return { ok: true }; + } + + if (geometryType === "MultiPolygon") { + const nextPolygons = cloneCoordinates(geometry.getCoordinates?.() ?? []); + const metaGroups = metaList.reduce>((acc, meta) => { + const polygonIndex = meta.polygonIndex ?? 0; + const ringIndex = meta.ringIndex ?? 0; + const groupKey = `${polygonIndex}:${ringIndex}`; + if (!acc[groupKey]) acc[groupKey] = []; + acc[groupKey]!.push(meta.coordIndex); + return acc; + }, {}); + + for (const [groupKey, coordIndexes] of Object.entries(metaGroups)) { + const [polygonIndexText, ringIndexText] = groupKey.split(":"); + const polygonIndex = Number(polygonIndexText); + const ringIndex = Number(ringIndexText); + const nextCoordinates = deletePolygonVertices( + nextPolygons[polygonIndex]?.[ringIndex] ?? [], + coordIndexes, + ); + if (!nextCoordinates) { + return { ok: false, message: "폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다." }; + } + nextPolygons[polygonIndex][ringIndex] = nextCoordinates; + } + + geometry.setCoordinates(nextPolygons); + return { ok: true }; + } + + return { ok: false, message: "현재 geometry 유형은 꼭지점 삭제를 지원하지 않습니다." }; +}; + +const getClosestSnapCoordinateFromSource = ( + source: VectorSource | null, + map: any, + coordinate: number[], + tolerancePx = 18, +) => { + if (!source || !Array.isArray(coordinate)) return null; + const nearestFeature = source.getClosestFeatureToCoordinate?.(coordinate); + const nearestGeometry = nearestFeature?.getGeometry?.(); + if (!nearestGeometry?.getClosestPoint) return null; + + const closestCoordinate = nearestGeometry.getClosestPoint(coordinate); + if (!Array.isArray(closestCoordinate)) return null; + + const distance = Math.sqrt(getSquaredPixelDistance(map, coordinate, closestCoordinate)); + if (!Number.isFinite(distance) || distance > tolerancePx) return null; + + return { + coordinate: closestCoordinate as number[], + distance, + }; +}; + +const applyGeometryVertexSnaps = ( + geometry: any, + source: VectorSource | null, + map: any, + tolerancePx = 18, +) => { + if (!geometry || !source || !map) return false; + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + let changed = false; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const snapResult = getClosestSnapCoordinateFromSource(source, map, candidate.coordinate, tolerancePx); + if (!snapResult) return; + + const currentKey = buildVertexCoordinateKey(candidate.coordinate); + const nextKey = buildVertexCoordinateKey(snapResult.coordinate); + if (currentKey === nextKey) return; + + applyVertexHandleMove(geometry, candidate.meta, snapResult.coordinate); + changed = true; + }); + + return changed; +}; + +const applyGeometryMoveSnap = ( + geometry: any, + source: VectorSource | null, + map: any, + anchorCoordinate?: number[] | null, + tolerancePx = 18, +) => { + if (!geometry || !source || !map || typeof geometry.translate !== "function") return false; + + if (Array.isArray(anchorCoordinate)) { + const snapResult = getClosestSnapCoordinateFromSource(source, map, anchorCoordinate, tolerancePx); + if (snapResult) { + const deltaX = Number(snapResult.coordinate[0] ?? 0) - Number(anchorCoordinate[0] ?? 0); + const deltaY = Number(snapResult.coordinate[1] ?? 0) - Number(anchorCoordinate[1] ?? 0); + if (deltaX !== 0 || deltaY !== 0) { + geometry.translate(deltaX, deltaY); + return true; + } + } + } + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + type GeometryMoveSnapCandidate = { + original: number[]; + snapped: number[]; + distance: number; + }; + let bestCandidate: GeometryMoveSnapCandidate | null = null; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const snapResult = getClosestSnapCoordinateFromSource(source, map, candidate.coordinate, tolerancePx); + if (!snapResult) return; + + if (!bestCandidate || snapResult.distance < bestCandidate.distance) { + bestCandidate = { + original: candidate.coordinate, + snapped: snapResult.coordinate, + distance: snapResult.distance, + }; + } + }); + + if (!bestCandidate) return false; + const resolvedBestCandidate = bestCandidate as GeometryMoveSnapCandidate; + + const deltaX = Number(resolvedBestCandidate.snapped[0] ?? 0) - Number(resolvedBestCandidate.original[0] ?? 0); + const deltaY = Number(resolvedBestCandidate.snapped[1] ?? 0) - Number(resolvedBestCandidate.original[1] ?? 0); + if (deltaX === 0 && deltaY === 0) return false; + + geometry.translate(deltaX, deltaY); + return true; +}; + +const getClosestGeometryVertexCoordinate = ( + geometry: any, + map: any, + coordinate: number[] | null, +) => { + if (!geometry || !map || !Array.isArray(coordinate)) return null; + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const distance = getSquaredPixelDistance(map, coordinate, candidate.coordinate); + if (distance < minimumDistance) { + minimumDistance = distance; + bestCandidate = candidate; + } + }); + + return (bestCandidate as VertexHandleCandidate | null)?.coordinate ?? null; +}; + +const getClosestGeometryVertexMeta = ( + geometry: any, + map: any, + coordinate: number[] | null, +) => { + if (!geometry || !map || !Array.isArray(coordinate)) return null; + + const vertexCandidates = collectVertexHandleCandidates(geometry).filter( + (candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex", + ); + let bestCandidate: VertexHandleCandidate | null = null; + let minimumDistance = Number.POSITIVE_INFINITY; + + vertexCandidates.forEach((candidate: VertexHandleCandidate) => { + const distance = getSquaredPixelDistance(map, coordinate, candidate.coordinate); + if (distance < minimumDistance) { + minimumDistance = distance; + bestCandidate = candidate; + } + }); + + return (bestCandidate as VertexHandleCandidate | null)?.meta ?? null; +}; + +const getGeometryVertexCoordinateByMeta = ( + geometry: any, + meta: VertexHandleMeta | null, +) => { + if (!geometry || !meta) return null; + const geometryType = geometry?.getType?.(); + if (!geometryType) return null; + + if (geometryType === "LineString") { + return geometry.getCoordinates?.()?.[meta.coordIndex] ?? null; + } + + if (geometryType === "MultiLineString") { + return geometry.getCoordinates?.()?.[meta.partIndex ?? 0]?.[meta.coordIndex] ?? null; + } + + if (geometryType === "Polygon") { + return geometry.getCoordinates?.()?.[meta.ringIndex ?? 0]?.[meta.coordIndex] ?? null; + } + + if (geometryType === "MultiPolygon") { + return geometry.getCoordinates?.()?.[meta.polygonIndex ?? 0]?.[meta.ringIndex ?? 0]?.[meta.coordIndex] ?? null; + } + + return null; +}; + +const isTypingElement = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) return false; + const tagName = target.tagName.toLowerCase(); + return tagName === "input" || tagName === "textarea" || tagName === "select" || target.isContentEditable; +}; + // 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다. const withAlpha = (color: string | null | undefined, alpha: number) => { const resolvedColor = String(color ?? "").trim(); @@ -366,8 +2265,19 @@ const createFlowDirectionStyle = ( const usageCode = String(feature.get("usgCd") ?? feature.get("usg_cd") ?? "").trim(); const usageStyle = usageStyleMap[usageCode]; const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR; + const isNetworkCorrected = String( + feature.get("networkCorrectionYn") ?? + feature.get("network_correction_yn") ?? + "", + ).toUpperCase() === "Y"; - const styleByConfidence: Record = { + const styleByConfidence: Record = { + HIGH: { + color: baseColor, + lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), + scale: 1, + width: 1.3, + }, MEDIUM: { color: baseColor, lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42), @@ -381,12 +2291,21 @@ const createFlowDirectionStyle = ( width: 1.1, }, }; - const style = styleByConfidence[confidence] ?? styleByConfidence.LOW!; + const style = isNetworkCorrected + ? { + color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, + lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5), + scale: 1.08, + width: 2, + lineDash: [8, 4], + } + : styleByConfidence[confidence] ?? styleByConfidence.LOW!; const styles = [ new Style({ stroke: new Stroke({ color: style.lineColor, width: style.width, + lineDash: style.lineDash, lineCap: "round", lineJoin: "round", }), @@ -435,8 +2354,8 @@ const extractUsageCodesFromLayerInfo = (layerInfo: any) => { // 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다. const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => { - const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom); - return !Number.isFinite(minZoom) || zoom >= minZoom; + const minZoom = getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom); + return minZoom === null || zoom >= minZoom; }; // 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다. @@ -446,6 +2365,7 @@ const extractCheckedPipeUsageStyles = (checkedNodesData: any[], zoom: number) => checkedNodesData.forEach((node) => { const data = node?.data ?? node; if (!data || isFlowDirectionLayerInfo(data)) return; + if (getLayerTypeName(data) !== FLOW_DIRECTION_PIPE_LAYER_TYPE) return; if (!isLayerVisibleAtZoom(data, zoom)) return; const usageCodes = extractUsageCodesFromLayerInfo(data); @@ -506,22 +2426,145 @@ const appendAgedPipeControlNode = (data: any[]) => { ]; }; +const normalizeTreeRowSpacing = (data: any[]) => { + if (!Array.isArray(data)) return data; + + return data.map((item) => { + if (item?.lyrNm !== "지반침하정보") { + return item; + } + + return { + ...item, + iconNm: item.iconNm || SINKHOLE_TREE_ICON_NAME, + gobjTy: "", + }; + }); +}; + +const appendSinkholeControlNode = (data: any[]) => { + if (!Array.isArray(data)) return data; + const existingRoot = data.find((item) => { + const nodeId = String(item?.lyrSn ?? item?.id ?? ""); + const layerName = String(item?.lyrNm ?? item?.name ?? "").trim(); + const layerTypeName = String(item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "").trim(); + return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME || layerTypeName === SINKHOLE_LAYER_TYPE; + }); + + const existingRootId = existingRoot?.lyrSn ?? existingRoot?.id ?? SINKHOLE_CONTROL_NODE_ID; + const filtered = data.filter((item) => { + const nodeId = String(item?.lyrSn ?? item?.id ?? ""); + const upperId = String(item?.upperLyrSn ?? item?.upId ?? ""); + const layerName = String(item?.lyrNm ?? item?.name ?? "").trim(); + const layerTypeName = String(item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "").trim(); + + if (nodeId === String(existingRootId)) return false; + if (upperId === String(existingRootId)) return false; + if (!existingRoot && nodeId === SINKHOLE_CONTROL_NODE_ID) return false; + if (!existingRoot && upperId === SINKHOLE_CONTROL_NODE_ID) return false; + if (!existingRoot && layerName === SINKHOLE_LAYER_NAME) return false; + if (!existingRoot && layerTypeName === SINKHOLE_LAYER_TYPE) return false; + return true; + }); + + const rootNode = existingRoot + ? { + ...existingRoot, + lyrSn: existingRootId, + id: existingRootId, + gobjTy: "", + iconNm: existingRoot.iconNm || SINKHOLE_TREE_ICON_NAME, + clickPsblYn: "N", + actlLyrYn: "N", + children: null, + } + : { + lyrSn: SINKHOLE_CONTROL_NODE_ID, + id: SINKHOLE_CONTROL_NODE_ID, + lyrNm: SINKHOLE_LAYER_NAME, + upperLyrSn: null, + upId: null, + sortOrdr: "3", + drawOrdr: null, + flterSql: null, + gobjTy: "", + lyrTyNm: SINKHOLE_LAYER_TYPE, + useYn: "Y", + minViewSize: null, + iconNm: SINKHOLE_TREE_ICON_NAME, + iconSize: null, + lblAtrbNm: null, + fontNm: null, + fontIntnColor: null, + fontExtnColor: null, + fontExtnWdth: null, + lnWdth: null, + lnColor: null, + polgnColor: null, + initEprssYn: "Y", + clickPsblYn: "N", + exprsYn: "Y", + cmncNm: "all", + srvcNm: "api", + actlLyrYn: "N", + clckZoom: null, + children: null, + }; + + return [...filtered, rootNode]; +}; + const Map = () => { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const initialAnalysisPopupKey = useMemo(() => { + const popupKey = searchParams.get("analysisPopupKey"); + if (!popupKey || !isWindowPopupKey(popupKey)) return null; + if (popupKey !== "inftwtResultPopup" && popupKey !== "rdiiResultPopup") return null; + return popupKey; + }, [searchParams]); + const flowDirectionMode = useMemo(() => getFlowDirectionModeByPathname(pathname), [pathname]); const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); + const userInfo = useUserStore((state) => state.userInfo); + const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]); const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 const [layerLoadError, setLayerLoadError] = useState(null); const gisMaps = useRef(null); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 - const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부 + // 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다. + const [rightPanelTabs, setRightPanelTabs] = useState([]); + const [activeRightPanelTabId, setActiveRightPanelTabId] = useState(null); + // 단일 관로 탭이 어느 지반침하 상세에서 열렸는지 비동기 피처 조회 사이에 보관한다. + const pipeParentTabIdRef = useRef(null); + const [isWaterDepthLayerActive, setIsWaterDepthLayerActive] = useState(false); // 침수심 레이어 활성 여부 const [isPlaying, setIsPlaying] = useState(false); const [mapReady, setMapReady] = useState(false); + const [checkedLayerLegendVersion, setCheckedLayerLegendVersion] = useState(0); const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); + const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); + const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false); + const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] = useState("all"); + const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState([]); const [draftAgedPipeFilters, setDraftAgedPipeFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] = useState(AGED_PIPE_DEFAULT_FILTERS); + const [sinkholeYearRange, setSinkholeYearRange] = useState(() => + buildSinkholeDefaultYearRange(), + ); + const [appliedSinkholeFilters, setAppliedSinkholeFilters] = useState(() => + normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(buildSinkholeDefaultYearRange(), sinkholeScope), sinkholeScope), + ); + const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] = useState(false); + const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(SINKHOLE_DEFAULT_RADIUS); + const [selectedFloodHistoryDetail, setSelectedFloodHistoryDetail] = useState(null); + const [floodHistoryDetailPopupPosition, setFloodHistoryDetailPopupPosition] = useState<{ left: number; top: number } | null>(null); + const [selectedSinkholeDetail, setSelectedSinkholeDetail] = useState(null); + const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] = useState([]); + const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] = useState(null); + const [sinkholeSelectionVersion, setSinkholeSelectionVersion] = useState(0); const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false); const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false); const [agedPipeRows, setAgedPipeRows] = useState([]); @@ -529,16 +2572,85 @@ const Map = () => { const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1); const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState(""); + const [isGisEditSaving, setIsGisEditSaving] = useState(false); const { ctpv, sgg } = useStore(); + // GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다. + const gisEdit = useGisEdit(treeData); + // 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다. + const inftwtResultMap = useInftwtResultMap({ + gisMaps, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, + }); + const rdiiResultMap = useRdiiResultMap({ + gisMaps, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, + }); const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); - const pipeAnalysisPopupWindowRef = useRef(null); + const sinkholePopupWindowRef = useRef(null); + // 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다. + const floodHistoryAreaLayerRef = useRef | null>(null); + const floodHistoryDepthLayerRef = useRef | null>(null); + const sinkholePopupCloseWatcherRef = useRef(null); + const pipeAnalysisPopupWindowRef = useRef(null); + const gisEditSessionPopupWindowRef = useRef(null); + const gisEditSessionPopupCloseWatcherRef = useRef(null); + const [gisEditSessionView, setGisEditSessionView] = useState<"inline" | "popup">("inline"); + const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState(null); + const gisEditDetailChangeRef = useRef(null); + const gisEditDetailFeatureDataRef = useRef(null); + const gisEditIsEditModeRef = useRef(false); + // vertex Delete는 현재 선택된 주황색 실꼭지점 묶음을 ref로 기억해 키보드 이벤트에서 즉시 사용한다. + const gisEditSelectedVertexHandleKeyRef = useRef(""); + const gisEditSelectedVertexMetaListRef = useRef(null); + const gisEditMoveGripMetaRef = useRef<{ changeId: string; meta: VertexHandleMeta | null } | null>(null); + const waterRiskPopupWindowRef = useRef(null); + const waterRiskPopupCloseWatcherRef = useRef(null); + const sinkholeLayerRef = useRef | null>(null); + const sinkholeSourceRef = useRef(null); + const sinkholeSelectedLayerRef = useRef | null>(null); + const sinkholeSelectedSourceRef = useRef(null); + const sinkholeFocusedPipeLayerRef = useRef | null>(null); + const sinkholeFocusedPipeSourceRef = useRef(null); // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState(null); // 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다. const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] = useState(0); + // 줌레벨 컨트롤에 현재 체크된 특수 레이어의 최소 표출 기준을 안내한다. + const zoomGuide = useMemo(() => { + const flowDirectionGuideZoom = flowDirectionLayerInfo + ? getFlowDirectionMinZoom(flowDirectionLayerInfo) + : FLOW_DIRECTION_DEFAULT_MIN_ZOOM; + + if (isAgedPipeChecked && isFlowDirectionChecked) { + return { + label: "노후관로/유하방향 기준", + zoom: Math.max(AGED_PIPE_GUIDE_MIN_ZOOM, flowDirectionGuideZoom), + }; + } + + if (isFlowDirectionChecked) { + return { + label: "유하방향 기준", + zoom: flowDirectionGuideZoom, + }; + } + + if (isAgedPipeChecked) { + return { + label: "노후관로 기준", + zoom: AGED_PIPE_GUIDE_MIN_ZOOM, + }; + } + + return undefined; + }, [flowDirectionLayerInfo, isAgedPipeChecked, isFlowDirectionChecked]); // moveend 이벤트 핸들러는 최신 state closure를 보장하지 않으므로 현재 체크 여부를 ref로 함께 보관한다. const flowDirectionCheckedRef = useRef(false); // 지도 이동 후 재조회할 때 최신 유하방향 레이어 설정을 참조하기 위한 ref이다. @@ -551,17 +2663,273 @@ const Map = () => { // 같은 관로가 겹치는 BBOX에서 반복 조회될 수 있으므로 고유키와 삽입 순서를 캐시로 관리한다. const flowDirectionFeatureKeySetRef = useRef>(new Set()); const flowDirectionFeatureKeyQueueRef = useRef([]); + // 관종 체크 변경 시 다른 관종 유하방향을 유지하기 위해 현재 source에 남겨둘 관종 코드를 별도로 추적한다. + const flowDirectionVisibleUsageCodesRef = useRef>(new Set()); + // 관종 체크 변경으로 새로 켜진 usgCd만 다음 조회 대상으로 넘겨 기존 관종 재요청을 막는다. + const flowDirectionSelectionRequestUsageCodesRef = useRef(null); // 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다. const flowDirectionRequestSeqRef = useRef(0); - // 지도 이동이 연속으로 발생하면 이전 bbox 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. + // 지도 이동이 연속으로 발생하면 이전 유하방향 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. const flowDirectionAbortControllerRef = useRef(null); // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. const flowDirectionFailureAlertedRef = useRef(false); + const gisEditPreviewLayerRef = useRef(null); + const gisEditPreviewSourceRef = useRef(null); + const gisEditPipeSnapSourceRef = useRef(null); + const gisEditPipeSnapAbortControllerRef = useRef(null); + const gisEditPipeSnapRequestSeqRef = useRef(0); + const gisEditOriginalLayerFilterRef = useRef>(new globalThis.Map()); + const gisEditOriginalFeatureStyleRef = useRef>(new globalThis.Map()); + const gisEditVertexLayerRef = useRef | null>(null); + const gisEditVertexSourceRef = useRef(null); + const waterRiskAreaLayerRef = useRef | null>(null); + const waterRiskAreaSourceRef = useRef(null); + const waterRiskGridLayerRef = useRef | null>(null); + const waterRiskGridSourceRef = useRef(null); + const waterRiskPipeLayerRef = useRef | null>(null); + const waterRiskPipeSourceRef = useRef(null); + const gisEditDrawRef = useRef(null); + const gisEditDrawSnapRef = useRef(null); + const gisEditTranslateRef = useRef(null); + const gisEditModifyRef = useRef(null); + // 스냅 interaction은 MOVE/VERTEX_EDIT 진입마다 다시 붙이므로 별도 ref로 정리한다. + const gisEditSnapRef = useRef(null); + const [waterRiskSession, setWaterRiskSession] = useState( + EMPTY_WATER_RISK_SESSION_SNAPSHOT, + ); + const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<[number, number] | null>(null); + const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState | null>(null); + const gisEditVisibleSessionChanges = useMemo( + () => gisEdit.changes.filter(isVisibleGisEditSessionChange), + [gisEdit.changes], + ); + const shouldShowGisEditSession = gisEdit.isEditMode; + const isGisEditSessionInlineVisible = shouldShowGisEditSession && gisEditSessionView === "inline"; + const gisEditSessionSnapshot = useMemo(() => ({ + isEditMode: gisEdit.isEditMode, + selectedLayerName: gisEdit.selectedLayer?.layerName ?? "", + activeTool: gisEdit.activeTool, + isSnapEnabled: gisEdit.isSnapEnabled, + changes: gisEditVisibleSessionChanges, + lastSavedPayload: gisEdit.lastSavedPayload, + canUndo: gisEdit.canUndo, + focusedChangeId: gisEdit.focusedChangeId, + }), [ + gisEdit.activeTool, + gisEdit.canUndo, + gisEdit.focusedChangeId, + gisEdit.isEditMode, + gisEdit.isSnapEnabled, + gisEdit.lastSavedPayload, + gisEdit.selectedLayer, + gisEditVisibleSessionChanges, + ]); + const gisEditDetailChange = useMemo( + () => gisEdit.changes.find((change) => change.changeId === gisEditDetailChangeId) ?? null, + [gisEdit.changes, gisEditDetailChangeId], + ); + const gisEditDetailFeatureData = useMemo(() => { + if (!gisEditDetailChange) return null; + if (featureData && !isGisEditPreviewFeatureData(featureData)) return featureData; + + if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null; + + const savedFeatureProperties = gisEditDetailChange.featureProperties ?? {}; + return { + ...savedFeatureProperties, + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: + gisEditDetailChange.featureRef?.fcltMngNo ?? + savedFeatureProperties["fclt_mng_no"] ?? + gisEditDetailChange.objectId ?? + "", + mngInstCd: + gisEditDetailChange.featureRef?.mngInstCd ?? + savedFeatureProperties["mng_inst_cd"] ?? + "", + fclt_mng_no: + gisEditDetailChange.featureRef?.fcltMngNo ?? + savedFeatureProperties["fclt_mng_no"] ?? + gisEditDetailChange.objectId ?? + "", + mng_inst_cd: + gisEditDetailChange.featureRef?.mngInstCd ?? + savedFeatureProperties["mng_inst_cd"] ?? + "", + }; + }, [featureData, gisEditDetailChange]); + gisEditDetailChangeRef.current = gisEditDetailChange; + gisEditDetailFeatureDataRef.current = gisEditDetailFeatureData; + gisEditIsEditModeRef.current = gisEdit.isEditMode; + const waterRiskSessionSnapshot = useMemo( + () => waterRiskSession, + [waterRiskSession], + ); + const syncGisEditSessionPopup = useCallback(() => { + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + return; + } + + popup.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC, + payload: gisEditSessionSnapshot, + }, + window.location.origin, + ); + }, [gisEditSessionSnapshot]); + + const closeGisEditSessionPopup = useCallback(() => { + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + + const popup = gisEditSessionPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + }, []); + + useEffect(() => () => { + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + }, []); + + const openGisEditPipeDetail = useCallback((changeId: string | null) => { + if (!changeId) { + setGisEditDetailChangeId(null); + return; + } + setGisEditDetailChangeId(changeId); + }, []); + + const focusGisEditChangeOnMap = useCallback((changeId: string) => { + const map = gisMaps.current; + const change = gisEdit.changes.find((item) => item.changeId === changeId); + const geometryObject = change?.editType === "DELETE" ? change?.beforeGeometry : change?.afterGeometry; + if (!map || !geometryObject) return; + + try { + const geometry = new GeoJSON().readGeometry(geometryObject, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }); + const extent = geometry?.getExtent?.(); + if ( + !extent || + extent.length < 4 || + extent.some((value: number) => !Number.isFinite(value)) + ) { + return; + } + + const view = map.getView?.(); + const currentZoom = Number(view?.getZoom?.()); + const maxZoom = Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18; + view?.fit?.(extent, { + duration: 350, + padding: [80, 420, 80, 80], + maxZoom, + }); + } catch (error) { + console.warn("GIS edit change focus failed:", error); + } + }, [gisEdit.changes]); + + // 인라인 세션과 윈도우 팝업이 동일한 지도 focus/상세 열기 흐름을 공유한다. + const handleFocusGisEditSessionChange = useCallback((changeId: string) => { + if (!changeId) return; + gisEdit.focusChange(changeId); + focusGisEditChangeOnMap(changeId); + setFeatureData(null); + openGisEditPipeDetail(changeId); + setIsOpen(true); + setIsSearchModalOpen(false); + }, [focusGisEditChangeOnMap, gisEdit, openGisEditPipeDetail, setIsSearchModalOpen]); + + const syncWaterRiskPopup = useCallback(() => { + const popup = waterRiskPopupWindowRef.current; + if (!popup || popup.closed) { + waterRiskPopupWindowRef.current = null; + return; + } + + popup.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC, + payload: waterRiskSessionSnapshot, + }, + window.location.origin, + ); + }, [waterRiskSessionSnapshot]); + + const selectedWaterRiskResultRow = useMemo( + () => getWaterRiskResultRow(waterRiskSession.selectedAreaId), + [waterRiskSession.selectedAreaId], + ); + + const syncWaterRiskInfoCardPosition = useCallback(() => { + const map = gisMaps.current; + if (!map || !waterRiskInfoAnchor) { + setWaterRiskInfoCardStyle(null); + return; + } + + const [mapWidth = 0, mapHeight = 0] = map.getSize?.() ?? []; + const [pixelX = 0, pixelY = 0] = map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? []; + const cardWidth = 244; + const cardHeight = 236; + const left = pixelX + 22; + const top = pixelY - cardHeight / 2; + + if ( + pixelX < 0 || + pixelY < 0 || + pixelX > mapWidth || + pixelY > mapHeight || + left > mapWidth || + top > mapHeight || + left + cardWidth < 0 || + top + cardHeight < 0 + ) { + setWaterRiskInfoCardStyle(null); + return; + } + + setWaterRiskInfoCardStyle({ + left, + top, + }); + }, [waterRiskInfoAnchor]); + + const setGisEditPreviewCursor = useCallback((cursor: string) => { + const targetElement = gisMaps.current?.getTargetElement?.(); + if (targetElement?.style) { + targetElement.style.cursor = cursor; + } + }, []); useEffect(() => { fetchData(); }, []); + useEffect(() => { + setAppliedSinkholeFilters((currentFilters) => { + if (isSinkholePopupFilterActive) { + return normalizeSinkholeFilters(currentFilters, sinkholeScope); + } + return normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), sinkholeScope); + }); + }, [isSinkholePopupFilterActive, sinkholeScope, sinkholeYearRange]); + useEffect(() => { const timer = window.setInterval(() => { if (gisMaps.current) { @@ -609,10 +2977,107 @@ const Map = () => { }; }, []); + useEffect(() => { + if (shouldShowGisEditSession) return; + + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + return; + } + + popup.close(); + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + }, [shouldShowGisEditSession]); + + useEffect(() => { + return () => { + const popup = gisEditSessionPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + gisEditSessionPopupWindowRef.current = null; + }; + }, []); + + useEffect(() => { + if (!gisEdit.isEditMode) { + setGisEditDetailChangeId(null); + return; + } + + if (!gisEditDetailChangeId) return; + if (!gisEditDetailChange) { + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + return; + } + + if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl" || gisEditDetailChange.editType === "DELETE") { + setGisEditDetailChangeId(null); + } + }, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]); + + useEffect(() => { + if (!gisEdit.isEditMode) return; + syncGisEditSessionPopup(); + }, [gisEdit.isEditMode, gisEditVisibleSessionChanges.length, syncGisEditSessionPopup]); + + useEffect(() => { + const handleGisEditSessionPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY) { + syncGisEditSessionPopup(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SHOW_INLINE) { + closeGisEditSessionPopup(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE) { + const changeId = String(data.payload?.changeId ?? "").trim(); + handleFocusGisEditSessionChange(changeId); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE) { + gisEdit.removeFocusedChange(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE) { + gisEdit.removeLastChange(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES) { + gisEdit.clearChanges(); + } + }; + + window.addEventListener("message", handleGisEditSessionPopupMessage); + return () => { + window.removeEventListener("message", handleGisEditSessionPopupMessage); + }; + }, [closeGisEditSessionPopup, gisEdit, handleFocusGisEditSessionChange, syncGisEditSessionPopup]); + const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); - setTreeData(appendAgedPipeControlNode(normalizeFlowDirectionLayerInfos(data))); + setTreeData( + appendAgedPipeControlNode( + appendSinkholeControlNode( + normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)), + ), + ), + ); setLayerLoadError(null); } catch (error) { const message = @@ -623,6 +3088,370 @@ const Map = () => { } }; + const clearWaterRiskPopupCloseWatcher = useCallback(() => { + if (waterRiskPopupCloseWatcherRef.current !== null) { + window.clearInterval(waterRiskPopupCloseWatcherRef.current); + waterRiskPopupCloseWatcherRef.current = null; + } + }, []); + + const ensureWaterRiskAreaLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (waterRiskAreaLayerRef.current && waterRiskAreaSourceRef.current) { + return waterRiskAreaSourceRef.current; + } + + const layers = map.getLayers?.().getArray?.() ?? []; + layers.forEach((existingLayer: any) => { + if (existingLayer?.get?.("id") === GIS_EDIT_PREVIEW_LAYER_ID) { + map.removeLayer(existingLayer); + } + }); + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9996, + }); + + layer.set("id", WATER_RISK_AREA_LAYER_ID); + layer.set("lyrNm", "통수위험 중점관리지역"); + layer.set("lyrTyNm", WATER_RISK_AREA_LAYER_ID); + map.addLayer(layer); + waterRiskAreaLayerRef.current = layer; + waterRiskAreaSourceRef.current = source; + return source; + }, []); + + const ensureWaterRiskGridLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (waterRiskGridLayerRef.current && waterRiskGridSourceRef.current) { + return waterRiskGridSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9995, + }); + + layer.set("id", WATER_RISK_GRID_LAYER_ID); + layer.set("lyrNm", "통수위험 예측강우 격자"); + layer.set("lyrTyNm", WATER_RISK_GRID_LAYER_ID); + map.addLayer(layer); + waterRiskGridLayerRef.current = layer; + waterRiskGridSourceRef.current = source; + return source; + }, []); + + const ensureWaterRiskPipeLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (waterRiskPipeLayerRef.current && waterRiskPipeSourceRef.current) { + return waterRiskPipeSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9998, + }); + + layer.set("id", WATER_RISK_PIPE_LAYER_ID); + layer.set("lyrNm", "통수위험 유출관로"); + layer.set("lyrTyNm", WATER_RISK_PIPE_LAYER_ID); + map.addLayer(layer); + waterRiskPipeLayerRef.current = layer; + waterRiskPipeSourceRef.current = source; + return source; + }, []); + + const clearWaterRiskLayers = useCallback(() => { + waterRiskAreaSourceRef.current?.clear(); + waterRiskGridSourceRef.current?.clear(); + waterRiskPipeSourceRef.current?.clear(); + }, []); + + const renderWaterRiskSelection = useCallback((areaId: string | null) => { + const map = gisMaps.current; + if (!map) return; + + const areaSource = ensureWaterRiskAreaLayer(); + const gridSource = ensureWaterRiskGridLayer(); + if (!areaSource || !gridSource) return; + + areaSource.clear(); + gridSource.clear(); + + if (!areaId) return; + + const areaData = getWaterRiskAreaMapData(areaId); + const resultRow = getWaterRiskResultRow(areaId); + if (!areaData || !resultRow) return; + + const areaFeature = new Feature({ + geometry: new Polygon(areaData.polygonCoordinates), + areaId: areaData.areaId, + areaName: areaData.areaName, + riskStatus: resultRow.riskStatus, + }); + areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature)); + areaSource.addFeature(areaFeature); + + areaData.gridCells.forEach((cell) => { + const cellFeature = new Feature({ + geometry: new Polygon(cell.polygonCoordinates), + gridId: cell.gridId, + rainQty: cell.rainQty, + colorLevel: cell.colorLevel, + }); + cellFeature.setStyle(createWaterRiskGridStyle(cellFeature)); + gridSource.addFeature(cellFeature); + }); + + const pipeSource = ensureWaterRiskPipeLayer(); + const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? []; + pipeSource?.clear(); + pipes.forEach((pipe) => { + if (!pipeSource) return; + const pipeFeature = new Feature({ + geometry: new LineString(pipe.lineCoordinates), + pipeId: pipe.pipeId, + displayLabel: pipe.displayLabel, + }); + pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature)); + pipeSource.addFeature(pipeFeature); + }); + + const areaGeometry = areaFeature.getGeometry(); + if (!areaGeometry) return; + + map.getView().fit(areaGeometry.getExtent(), { + duration: 300, + padding: [120, 120, 120, 120], + maxZoom: 15, + constrainResolution: true, + nearest: true, + }); + }, [ensureWaterRiskAreaLayer, ensureWaterRiskGridLayer, ensureWaterRiskPipeLayer]); + + const setWaterRiskStatusMessage = useCallback((statusMessage: string) => { + setWaterRiskSession((current) => ({ + ...current, + statusMessage, + })); + }, []); + + const startWaterRiskSettingEdit = useCallback((areaId: string, areaName: string) => { + setWaterRiskSession((current) => { + const shouldSeedPipes = + current.selectedSettingAreaId !== areaId || current.stagedPipes.length === 0; + + return { + ...current, + selectedAreaId: areaId, + selectedSettingAreaId: areaId, + selectedSettingAreaName: areaName, + isSettingEditMode: true, + stagedPipes: shouldSeedPipes ? buildWaterRiskSeedPipes(areaId) : current.stagedPipes, + statusMessage: `${areaName} 설정 편집 모드입니다. 지도에서 하수관로를 클릭하세요.`, + }; + }); + setIsOpen(false); + setFeatureData(null); + }, []); + + const stopWaterRiskSettingEdit = useCallback(() => { + setWaterRiskSession((current) => ({ + ...current, + isSettingEditMode: false, + statusMessage: "", + })); + }, []); + + const removeWaterRiskStagedPipe = useCallback((pipeId: string) => { + setWaterRiskSession((current) => ({ + ...current, + stagedPipes: current.stagedPipes + .filter((pipe) => pipe.pipeId !== pipeId) + .map((pipe, index) => ({ + ...pipe, + setSeq: index + 1, + })), + statusMessage: `${pipeId} 관로를 staged 목록에서 제거했습니다.`, + })); + }, []); + + const reorderWaterRiskStagedPipe = useCallback((pipeId: string, direction: "UP" | "DOWN") => { + setWaterRiskSession((current) => { + const currentIndex = current.stagedPipes.findIndex((pipe) => pipe.pipeId === pipeId); + if (currentIndex < 0) return current; + + const nextIndex = + direction === "UP" ? currentIndex - 1 : currentIndex + 1; + if (nextIndex < 0 || nextIndex >= current.stagedPipes.length) { + return current; + } + + const reordered = [...current.stagedPipes]; + const [targetPipe] = reordered.splice(currentIndex, 1); + if (!targetPipe) { + return current; + } + reordered.splice(nextIndex, 0, targetPipe); + + return { + ...current, + stagedPipes: reordered.map((pipe, index) => ({ + ...pipe, + setSeq: index + 1, + })), + statusMessage: `${pipeId} 관로 순번을 조정했습니다.`, + }; + }); + }, []); + + const clearWaterRiskStagedPipes = useCallback(() => { + setWaterRiskSession((current) => ({ + ...current, + stagedPipes: [], + statusMessage: "staged 유출관로 목록을 초기화했습니다.", + })); + }, []); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + if (!waterRiskSession.selectedAreaId) { + clearWaterRiskLayers(); + setWaterRiskInfoAnchor(null); + setWaterRiskInfoCardStyle(null); + return; + } + + renderWaterRiskSelection(waterRiskSession.selectedAreaId); + }, [ + clearWaterRiskLayers, + mapReady, + renderWaterRiskSelection, + waterRiskSession.selectedAreaId, + ]); + + useEffect(() => { + setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId)); + }, [waterRiskSession.selectedAreaId]); + + useEffect(() => { + const popup = waterRiskPopupWindowRef.current; + if (!popup || popup.closed) return; + syncWaterRiskPopup(); + }, [syncWaterRiskPopup, waterRiskSession]); + + useEffect(() => { + if (!mapReady || !gisMaps.current || !waterRiskInfoAnchor) return; + + const map = gisMaps.current; + const syncDuringMapRender = () => { + syncWaterRiskInfoCardPosition(); + }; + + syncWaterRiskInfoCardPosition(); + map.on?.("postrender", syncDuringMapRender); + map.on?.("moveend", syncDuringMapRender); + + return () => { + map.un?.("postrender", syncDuringMapRender); + map.un?.("moveend", syncDuringMapRender); + }; + }, [mapReady, syncWaterRiskInfoCardPosition, waterRiskInfoAnchor]); + + useEffect(() => { + const handleWaterRiskPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY) { + syncWaterRiskPopup(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) { + const areaId = String(data.payload?.areaId ?? "").trim(); + if (!areaId) return; + setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(areaId)); + setWaterRiskSession((current) => ({ + ...current, + selectedAreaId: areaId, + statusMessage: `${getWaterRiskResultRow(areaId)?.areaName ?? areaId} 지도 강조를 갱신했습니다.`, + })); + setIsOpen(false); + setFeatureData(null); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT) { + const areaId = String(data.payload?.areaId ?? "").trim(); + const areaName = String(data.payload?.areaName ?? "").trim(); + if (!areaId) return; + startWaterRiskSettingEdit(areaId, areaName || getWaterRiskResultRow(areaId)?.areaName || areaId); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT) { + stopWaterRiskSettingEdit(); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE) { + const pipeId = String(data.payload?.pipeId ?? "").trim(); + if (pipeId) { + removeWaterRiskStagedPipe(pipeId); + } + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE) { + const pipeId = String(data.payload?.pipeId ?? "").trim(); + const direction = data.payload?.direction === "UP" ? "UP" : "DOWN"; + if (pipeId) { + reorderWaterRiskStagedPipe(pipeId, direction); + } + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES) { + clearWaterRiskStagedPipes(); + } + }; + + window.addEventListener("message", handleWaterRiskPopupMessage); + return () => { + window.removeEventListener("message", handleWaterRiskPopupMessage); + }; + }, [ + clearWaterRiskStagedPipes, + removeWaterRiskStagedPipe, + reorderWaterRiskStagedPipe, + startWaterRiskSettingEdit, + stopWaterRiskSettingEdit, + syncWaterRiskPopup, + ]); + + useEffect(() => { + return () => { + clearWaterRiskPopupCloseWatcher(); + const popup = waterRiskPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + waterRiskPopupWindowRef.current = null; + }; + }, [clearWaterRiskPopupCloseWatcher]); + // 유하방향은 일반 Gis core 레이어 등록 흐름을 타지 않으므로 전용 layer/source/cache를 직접 정리한다. const removeFlowDirectionLayer = useCallback(() => { const core = GisApp.Module.core as any; @@ -631,6 +3460,7 @@ const Map = () => { flowDirectionSourceRef.current = null; flowDirectionFeatureKeySetRef.current.clear(); flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); }, []); // 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다. @@ -638,8 +3468,46 @@ const Map = () => { flowDirectionSourceRef.current?.clear(); flowDirectionFeatureKeySetRef.current.clear(); flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); }, []); + // 체크 해제된 관종의 유하방향 feature/cache만 제거하고, 새로 체크된 관종 코드만 다음 요청 대상으로 돌려준다. + const retainFlowDirectionUsageCodes = useCallback((usageCodes: string[]) => { + const nextUsageCodes = new Set(usageCodes); + const addedUsageCodes = usageCodes.filter((usageCode) => + !flowDirectionVisibleUsageCodesRef.current.has(usageCode), + ); + const source = flowDirectionSourceRef.current; + if (source) { + source.getFeatures().forEach((feature: any) => { + const usageCode = String(feature.get("usgCd") ?? feature.get("usg_cd") ?? "").trim(); + if (nextUsageCodes.has(usageCode)) return; + + const featureKey = feature.get("flowDirectionCacheKey"); + if (featureKey) { + flowDirectionFeatureKeySetRef.current.delete(String(featureKey)); + } + source.removeFeature(feature); + }); + flowDirectionFeatureKeyQueueRef.current = flowDirectionFeatureKeyQueueRef.current.filter((featureKey) => + flowDirectionFeatureKeySetRef.current.has(featureKey), + ); + } + flowDirectionVisibleUsageCodesRef.current = new Set( + Array.from(flowDirectionVisibleUsageCodesRef.current).filter((usageCode) => + nextUsageCodes.has(usageCode), + ), + ); + return addedUsageCodes; + }, []); + + useEffect(() => { + if (!flowDirectionCheckedRef.current) return; + clearFlowDirectionFeatureCache(); + flowDirectionSelectionRequestUsageCodesRef.current = null; + setFlowDirectionSelectionVersion((version) => version + 1); + }, [clearFlowDirectionFeatureCache, flowDirectionMode]); + // 유하방향 레이어는 하나만 유지하고 source에 피처를 누적한다. const ensureFlowDirectionLayer = useCallback((layerInfo: any) => { const map = gisMaps.current; @@ -705,23 +3573,11 @@ const Map = () => { source.addFeatures(nextFeatures); } - while (flowDirectionFeatureKeyQueueRef.current.length > FLOW_DIRECTION_CACHE_MAX_FEATURES) { - const staleKey = flowDirectionFeatureKeyQueueRef.current.shift(); - if (!staleKey) break; - - const staleFeature = source.getFeatures().find((feature: any) => - feature.get("flowDirectionCacheKey") === staleKey, - ); - if (staleFeature) { - source.removeFeature(staleFeature); - } - flowDirectionFeatureKeySetRef.current.delete(staleKey); - } }, [ensureFlowDirectionLayer]); - // 현재 지도 BBOX와 체크된 관종 필터로 유하방향 GeoJSON을 조회하고, 최신 요청만 지도에 반영한다. + // 현재 지도 BBOX와 체크된 관종별 필터로 유하방향 GeoJSON을 각각 조회하고, 최신 요청만 지도에 반영한다. const loadFlowDirectionLayer = useCallback( - async (layerInfo: any) => { + async (layerInfo: any, requestOnlyUsageCodes?: string[]) => { const map = gisMaps.current; if (!map) return; @@ -749,45 +3605,113 @@ const Map = () => { removeFlowDirectionLayer(); return; } + retainFlowDirectionUsageCodes(usageCodes); + + const usageCodeSet = new Set(usageCodes); + const requestUsageCodes = requestOnlyUsageCodes + ? requestOnlyUsageCodes.filter((usageCode) => usageCodeSet.has(usageCode)) + : usageCodes; + const isPartialUsageRequest = Array.isArray(requestOnlyUsageCodes); + if (requestUsageCodes.length === 0) { + if (flowDirectionAbortControllerRef.current === abortController) { + flowDirectionAbortControllerRef.current = null; + } + return; + } const screenExtent = view.calculateExtent(size); const requestExtent = expandFlowDirectionExtent(screenExtent); const screenBbox = formatFlowDirectionBbox(screenExtent); const requestBbox = formatFlowDirectionBbox(requestExtent); - const params = new URLSearchParams({ - bbox: requestBbox, + const baseParams = new URLSearchParams({ srs: FLOW_DIRECTION_SRS, - limit: String(FLOW_DIRECTION_DEFAULT_LIMIT), - usgCd: usageCodes.join(","), + directionMode: flowDirectionMode, + searchStrategy: "BBOX", + bbox: requestBbox, }); try { - const payload = await getRawJson( - `/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`, - { signal: abortController.signal }, - ); + const settledResponses = await Promise.allSettled(requestUsageCodes.map(async (usageCode) => { + const params = new URLSearchParams(baseParams); + params.set("usgCd", usageCode); + const payload = await getRawJson( + `/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`, + { signal: abortController.signal }, + ); + return { usageCode, featureCollection: getFeatureCollection(payload) }; + })); if (requestSeq !== flowDirectionRequestSeqRef.current) return; + if (abortController.signal.aborted) return; - const featureCollection = getFeatureCollection(payload); - renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap); + const responses = settledResponses + .filter((result): result is PromiseFulfilledResult<{ usageCode: string; featureCollection: any }> => + result.status === "fulfilled", + ) + .map((result) => result.value); + const failedResponses = settledResponses.filter((result) => result.status === "rejected"); + + if (responses.length === 0) { + if (!isPartialUsageRequest) { + removeFlowDirectionLayer(); + } + throw failedResponses[0]?.reason ?? new Error("유하방향 관종별 조회 결과가 없습니다."); + } + + if (failedResponses.length > 0) { + console.error("유하방향 일부 관종 조회 실패:", failedResponses.map((result) => result.reason)); + if (!flowDirectionFailureAlertedRef.current) { + flowDirectionFailureAlertedRef.current = true; + } + } + + responses.forEach(({ usageCode, featureCollection }) => { + renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap); + flowDirectionVisibleUsageCodesRef.current.add(usageCode); + }); + const returnedFeaturesByUsage = responses.reduce>( + (acc, { usageCode, featureCollection }) => { + acc[usageCode] = featureCollection.features?.length ?? 0; + return acc; + }, + {}, + ); + const networkCorrectedFeatures = responses.reduce((count, { featureCollection }) => { + const correctedCount = featureCollection.features?.filter((feature: any) => + String( + feature?.properties?.networkCorrectionYn ?? + feature?.properties?.network_correction_yn ?? + "", + ).toUpperCase() === "Y", + ).length ?? 0; + return count + correctedCount; + }, 0); (window as any).__SN_FLOW_DIRECTION_DEBUG__ = { screenBbox, requestBbox, requestSeq, zoom, + directionMode: flowDirectionMode, + searchStrategy: "BBOX", usageCodes, - returnedFeatures: featureCollection.features?.length ?? 0, + requestUsageCodes, + requestCount: responses.length, + returnedFeaturesByUsage, + returnedFeatures: Object.values(returnedFeaturesByUsage).reduce((sum, count) => sum + count, 0), + networkCorrectedFeatures, cachedFeatures: flowDirectionSourceRef.current?.getFeatures?.().length ?? 0, }; - flowDirectionFailureAlertedRef.current = false; + if (failedResponses.length === 0) { + flowDirectionFailureAlertedRef.current = false; + } } catch (error) { if (requestSeq !== flowDirectionRequestSeqRef.current) return; if (typeof error === "object" && error !== null && "name" in error && error.name === "AbortError") return; console.error("유하방향 레이어 조회 실패:", error); - removeFlowDirectionLayer(); + if (!isPartialUsageRequest) { + removeFlowDirectionLayer(); + } if (!flowDirectionFailureAlertedRef.current) { - alert("유하방향 정보를 불러오지 못했습니다."); flowDirectionFailureAlertedRef.current = true; } } finally { @@ -796,11 +3720,189 @@ const Map = () => { } } }, - [removeFlowDirectionLayer, renderFlowDirectionLayer], + [flowDirectionMode, removeFlowDirectionLayer, renderFlowDirectionLayer, retainFlowDirectionUsageCodes], ); // SearchModal, GisContent 등에서 호출 const onFeatureSelect = (data: any) => { + const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data); + if (clickedLayerTypeName === "tb_ms_swage_ppl") { + pipeParentTabIdRef.current = null; + } + + // 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다. + clearSinkholeFocusedPipeLayer(); + if (rdiiResultMap.handleOverlayFeatureSelect(data)) { + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + if (inftwtResultMap.handleOverlayFeatureSelect(data)) { + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + if ( + gisEdit.isEditMode && + (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") && + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" && + clickedLayerTypeName !== "tb_ms_swage_ppl" + ) { + gisEdit.announceStatus("하수관로를 선택해주세요."); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + if (clickedLayerTypeName === SINKHOLE_LAYER_TYPE) { + const acdntNo = getSinkholeAccidentId(data); + if (acdntNo) { + void loadSinkholeDetail(acdntNo, false, getSinkholePointFromSelection(data), getSinkholeFallbackDetailFromSelection(data)); + } + return; + } + + // 편집 도구가 켜져 있을 때는 상세 팝업보다 도구별 세션 처리를 우선한다. + // ADD는 Draw interaction만 geometry를 받아야 하므로 기존 관로 선택 이벤트는 무시한다. + let editSelection: any = gisEdit.registerFeatureSelection(data); + if ( + !editSelection.handled && + gisEdit.isEditMode && + !gisEdit.activeTool && + clickedLayerTypeName === "tb_ms_swage_ppl" + ) { + editSelection = gisEdit.registerPipeDetailSelection(data); + } + if (editSelection.handled) { + const closeGisEditDetail = () => { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setFeatureData(null); + setGisEditDetailChangeId(null); + setIsOpen(false); + setIsSearchModalOpen(false); + }; + const loadLatestEditSn = (changeId: string) => { + const fid = editSelection.isPreviewSelection === true ? "" : getGisEditFeatureFid(data); + if (!fid) return; + getRawJson(`/sewerage-service/api/v1/map/gisEdit/swagePpl/histories?fid=${encodeURIComponent(fid)}`) + .then((historyResponse) => { + const latestEditSn = getLatestNumericEditSn(historyResponse?.histories ?? []); + if (latestEditSn !== null) { + gisEdit.updateChangeBaseEditSn(changeId, String(latestEditSn)); + } + }) + .catch((error) => { + console.error("GIS 편집 관로 이력 조회 실패:", error); + gisEdit.announceStatus("관로 이력 정보를 확인하지 못했습니다. 저장 시 충돌 검증이 제한될 수 있습니다."); + }); + }; + + if (gisEdit.activeTool === "ADD" || gisEdit.activeTool === "DELETE") { + closeGisEditDetail(); + return; + } + + const isGeometryEditTargetSelection = + editSelection.isPreviewSelection !== true && + (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT"); + + if (isGeometryEditTargetSelection) { + if (typeof editSelection.changeId === "string" && editSelection.changeId) { + loadLatestEditSn(String(editSelection.changeId)); + } + closeGisEditDetail(); + return; + } + + const canOpenPreviewPipeDetail = + clickedLayerTypeName === "tb_ms_swage_ppl" && + editSelection.isPreviewSelection === true && + editSelection.editType !== "DELETE"; + const shouldOpenPipeDetail = + clickedLayerTypeName === "tb_ms_swage_ppl" && + typeof editSelection.changeId === "string" && + editSelection.changeId && + (editSelection.isPreviewSelection !== true || canOpenPreviewPipeDetail); + + if (shouldOpenPipeDetail) { + openGisEditPipeDetail(String(editSelection.changeId)); + loadLatestEditSn(String(editSelection.changeId)); + if (editSelection.isPreviewSelection !== true && !isGeometryEditTargetSelection) { + highlightGisEditSelectedFeature(data); + } + setFeatureData(data); + window.setTimeout(() => { + setGisEditDetailChangeId(String(editSelection.changeId)); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + return; + } + + // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 + // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. + closeGisEditDetail(); + return; + } + + if (waterRiskSession.isSettingEditMode) { + const layerTypeName = getSelectedFeatureLayerTypeName(data); + if (layerTypeName !== WATER_RISK_PIPE_LAYER_TYPE) { + setWaterRiskStatusMessage("통수위험 설정 모드에서는 하수관로만 선택할 수 있습니다."); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + const nextPipe = buildWaterRiskPipeFromFeature(data); + if (!nextPipe) { + setWaterRiskStatusMessage("선택한 하수관로 정보를 staged 목록으로 옮기지 못했습니다."); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + setWaterRiskSession((current) => { + if (current.stagedPipes.some((pipe) => pipe.pipeId === nextPipe.pipeId)) { + return { + ...current, + statusMessage: `${nextPipe.pipeId} 관로는 이미 staged 목록에 있습니다.`, + }; + } + + if (current.stagedPipes.length >= 3) { + return { + ...current, + statusMessage: "유출관로는 최대 3건까지만 staged 목록에 담을 수 있습니다.", + }; + } + + return { + ...current, + stagedPipes: [ + ...current.stagedPipes, + { + ...nextPipe, + setSeq: current.stagedPipes.length + 1, + }, + ], + statusMessage: `${nextPipe.pipeId} 관로를 staged 목록에 추가했습니다.`, + }; + }); + + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + let map = gisMaps.current; if (!map) { console.warn("지도가 초기화되지 않았습니다."); @@ -867,12 +3969,27 @@ const Map = () => { console.warn("OpenLayers Feature를 찾을 수 없습니다:", data); } + setFeatureData(data); setIsOpen(true); setIsSearchModalOpen(false); }; - const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }]; + const swagePrcsFcltSearchFilter = useMemo(() => { + switch (swagePrcsFcltLayerMode) { + case "gte500": + return { label: "하수처리시설(500톤 이상)", cqlFilter: "fclt_cpct >= 500" }; + case "lt500": + return { label: "하수처리시설(500톤 미만)", cqlFilter: "fclt_cpct < 500" }; + case "none": + return { label: "하수처리시설(레이어 꺼짐)", disabled: true }; + case "all": + default: + return { label: "하수처리시설(전체)" }; + } + }, [swagePrcsFcltLayerMode]); + + const facilityTypes = [{ text: "하수처리시설", val: SWAGE_PRCS_FCLT_LAYER_TYPE }]; const fetchAgedPipeRows = useCallback( async (page: number, filters: AgedPipeFilters, size: number) => { @@ -975,6 +4092,883 @@ const Map = () => { openAgedPipePopupWindow(); }, [openAgedPipePopupWindow]); + const removeSinkholeLayer = useCallback(() => { + const core = GisApp.Module.core as any; + core?.deleteLayer?.(SINKHOLE_LAYER_ID); + sinkholeLayerRef.current = null; + sinkholeSourceRef.current = null; + }, []); + + const ensureSinkholeLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (sinkholeLayerRef.current && sinkholeSourceRef.current) { + return sinkholeSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9992, + updateWhileAnimating: false, + updateWhileInteracting: false, + }); + + layer.set("id", SINKHOLE_LAYER_ID); + layer.set("lyrNm", SINKHOLE_LAYER_NAME); + layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE); + layer.set("clickPsblYn", "Y"); + layer.set("chcYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(SINKHOLE_LAYER_ID); + map.addLayer(layer); + sinkholeLayerRef.current = layer; + sinkholeSourceRef.current = source; + return source; + }, []); + + const clearSinkholeSelectionLayer = useCallback(() => { + const core = GisApp.Module.core as any; + core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID); + sinkholeSelectedLayerRef.current = null; + sinkholeSelectedSourceRef.current = null; + }, []); + + const clearSinkholeFocusedPipeLayer = useCallback(() => { + const core = GisApp.Module.core as any; + core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER"); + sinkholeFocusedPipeLayerRef.current = null; + sinkholeFocusedPipeSourceRef.current = null; + }, []); + + const ensureSinkholeSelectionLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (sinkholeSelectedLayerRef.current && sinkholeSelectedSourceRef.current) { + return sinkholeSelectedSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9996, + updateWhileAnimating: false, + updateWhileInteracting: false, + }); + + layer.set("id", SINKHOLE_SELECTED_LAYER_ID); + layer.set("lyrNm", `${SINKHOLE_LAYER_NAME}-선택`); + layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID); + map.addLayer(layer); + sinkholeSelectedLayerRef.current = layer; + sinkholeSelectedSourceRef.current = source; + return source; + }, []); + + const ensureSinkholeFocusedPipeLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (sinkholeFocusedPipeLayerRef.current && sinkholeFocusedPipeSourceRef.current) { + return sinkholeFocusedPipeSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9999, + updateWhileAnimating: false, + updateWhileInteracting: false, + }); + + layer.set("id", "SN_SINKHOLE_FOCUSED_PIPE_LAYER"); + layer.set("lyrNm", "지반침하-관로강조"); + layer.set("lyrTyNm", "tb_ms_swage_ppl"); + layer.set("clickPsblYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER"); + map.addLayer(layer); + sinkholeFocusedPipeLayerRef.current = layer; + sinkholeFocusedPipeSourceRef.current = source; + return source; + }, []); + + const renderSelectedSinkhole = useCallback( + (detail: SinkholeDetail | null, radiusMeters: number) => { + if (!detail || detail.x == null || detail.y == null) { + clearSinkholeSelectionLayer(); + return; + } + + const source = ensureSinkholeSelectionLayer(); + if (!source) return; + source.clear(); + + const pointFeature = new Feature({ + geometry: new Point([detail.x, detail.y]), + }); + const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd); + pointFeature.setStyle(createSinkholePointStyle(pointVariant, true)); + + const radiusFeature = new Feature({ + geometry: new CircleGeom([detail.x, detail.y], radiusMeters), + }); + radiusFeature.setStyle(createSinkholeRadiusStyle(pointVariant)); + + // 원의 북쪽 경계에 라벨을 두고 같은 source에서 관리해 선택 해제 시 함께 정리한다. + const radiusLabelFeature = new Feature({ + geometry: new Point([detail.x, detail.y + radiusMeters]), + }); + radiusLabelFeature.set("sinkholeRadiusLabel", true); + radiusLabelFeature.setStyle(createSinkholeRadiusLabelStyle(radiusMeters, pointVariant)); + + source.addFeatures([radiusFeature, radiusLabelFeature, pointFeature]); + }, + [clearSinkholeSelectionLayer, ensureSinkholeSelectionLayer], + ); + + const focusSinkholeOnMap = useCallback((detail: SinkholeDetail, radiusMeters: number) => { + if (!gisMaps.current || detail.x == null || detail.y == null) return; + + const radiusGeometry = new CircleGeom([detail.x, detail.y], radiusMeters); + gisMaps.current.getView?.().fit?.(radiusGeometry.getExtent(), { + duration: 350, + padding: [100, 380, 100, 120], + maxZoom: 17, + constrainResolution: true, + nearest: true, + }); + }, []); + + // 우측 상세 탭 전환 시 패널의 활성 대상과 지도 선택/강조 상태를 맞춘다. + const syncRightPanelFeatureSelectionOnMap = useCallback( + (data: any) => { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + + const layerTypeName = getSelectedFeatureLayerTypeName(data); + if (layerTypeName === SINKHOLE_LAYER_TYPE) { + clearSinkholeFocusedPipeLayer(); + + return; + } + + if (!data || typeof data.getGeometry !== "function") { + clearSinkholeFocusedPipeLayer(); + + return; + } + + if (layerTypeName === "tb_ms_swage_ppl") { + const source = ensureSinkholeFocusedPipeLayer(); + if (!source) return; + + source.clear(); + const focusedFeature = typeof data.clone === "function" ? data.clone() : data; + if (typeof focusedFeature.setId === "function" && typeof data.getId === "function") { + focusedFeature.setId(data.getId()); + } + focusedFeature.setStyle(createSinkholeFocusedPipeStyle()); + source.addFeature(focusedFeature); + select?.selectFeature?.(focusedFeature, true); + return; + } + + clearSinkholeFocusedPipeLayer(); + + select?.selectFeature?.(data, true); + }, + [clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer], + ); + + const ensureFloodHistoryAreaSource = useCallback(() => { + if (!mapReady || !gisMaps.current) return null; + + if (!floodHistoryAreaLayerRef.current) { + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9993, + }); + layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID); + layer.set("lyrNm", "침수흔적도"); + layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + gisMaps.current.addLayer(layer); + floodHistoryAreaLayerRef.current = layer; + } + + return floodHistoryAreaLayerRef.current.getSource(); + }, [mapReady]); + + const ensureFloodHistoryDepthSource = useCallback(() => { + if (!mapReady || !gisMaps.current) return null; + + if (!floodHistoryDepthLayerRef.current) { + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 9992, + }); + layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID); + layer.set("lyrNm", "침수흔적도"); + layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + gisMaps.current.addLayer(layer); + floodHistoryDepthLayerRef.current = layer; + } + + return floodHistoryDepthLayerRef.current.getSource(); + }, [mapReady]); + + const clearFloodHistoryLayers = useCallback(() => { + floodHistoryAreaLayerRef.current?.getSource?.()?.clear?.(); + floodHistoryDepthLayerRef.current?.getSource?.()?.clear?.(); + }, []); + + const renderSelectedFloodHistory = useCallback( + (detail: FloodHistoryItem | null) => { + const areaSource = ensureFloodHistoryAreaSource(); + const depthSource = ensureFloodHistoryDepthSource(); + if (!areaSource || !depthSource) return; + + areaSource.clear(); + depthSource.clear(); + + if (!detail) return; + + const areaFeature = new Feature({ + geometry: new Polygon([detail.areaRing]), + }); + areaFeature.setStyle(createFloodHistoryAreaStyle(`${detail.floodYear}년 ${detail.districtName}`)); + areaSource.addFeature(areaFeature); + + detail.depthZones.forEach((zone) => { + const zoneFeature = new Feature({ + geometry: new Polygon([zone.ring]), + }); + zoneFeature.setStyle(createFloodHistoryDepthStyle(zone.depthLabel, zone.color)); + depthSource.addFeature(zoneFeature); + }); + }, + [ensureFloodHistoryAreaSource, ensureFloodHistoryDepthSource], + ); + + const focusFloodHistoryOnMap = useCallback((detail: FloodHistoryItem) => { + if (!gisMaps.current) return; + + const geometry = new Polygon([detail.areaRing]); + gisMaps.current.getView?.().fit?.(geometry.getExtent(), { + duration: 350, + padding: FLOOD_HISTORY_SELECTION_PADDING, + maxZoom: 15, + constrainResolution: true, + nearest: true, + }); + }, []); + + const clearFloodHistorySelection = useCallback(() => { + setSelectedFloodHistoryDetail(null); + setFloodHistoryDetailPopupPosition(null); + clearFloodHistoryLayers(); + }, [clearFloodHistoryLayers]); + + const closeFloodHistoryDetail = useCallback(() => { + setFloodHistoryDetailPopupPosition(null); + }, []); + + const handleFloodHistoryRowSelect = useCallback( + (detail: FloodHistoryItem) => { + setSelectedFloodHistoryDetail(detail); + renderSelectedFloodHistory(detail); + focusFloodHistoryOnMap(detail); + setIsSearchModalOpen(false); + }, + [focusFloodHistoryOnMap, renderSelectedFloodHistory, setIsSearchModalOpen], + ); + + const openFloodHistoryPopupWindow = useCallback(() => { + const popup = openWindowPopup(FLOOD_HISTORY_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + return popup; + }, []); + + const buildSinkholeQueryString = useCallback( + (filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT) => + buildSinkholeListQueryString(filters, ctpv, sgg, page, size), + [ctpv, sgg], + ); + + const loadSinkholeLayer = useCallback( + async (filters = appliedSinkholeFilters) => { + if (!mapReady || !gisMaps.current || !isSinkholeChecked) return; + + const source = ensureSinkholeLayer(); + if (!source) return; + + const effectiveFilters = normalizeSinkholeFilters(filters, sinkholeScope); + try { + const params = new URLSearchParams(buildSinkholeQueryString(effectiveFilters, 0, SINKHOLE_DEFAULT_POINT_LIMIT)); + const rawResponse = await getRawJson( + `/sewerage-service/api/v1/sinkholes?${params.toString()}`, + ); + const items = Array.isArray(rawResponse?.data?.items) + ? (rawResponse.data.items as SinkholeListItem[]) + : Array.isArray(rawResponse?.items) + ? (rawResponse.items as SinkholeListItem[]) + : []; + const featureCollection = buildSinkholeFeatureCollectionFromItems(items); + + if (!featureCollection) { + console.warn("지반침하 목록 응답을 feature collection으로 변환하지 못했습니다.", rawResponse); + source.clear(); + return; + } + + const features = new GeoJSON().readFeatures(featureCollection, { + dataProjection: "EPSG:5179", + featureProjection: GisApp.TO_PROJ, + }); + source.clear(); + features.forEach((feature: any) => { + const variant = resolveSinkholePointVariant( + String(feature?.get?.("ocrnYmd") ?? ""), + ); + feature.setStyle(createSinkholePointStyle(variant, false)); + }); + source.addFeatures(features); + } catch (error) { + console.error("지반침하 레이어 조회 실패:", error); + source.clear(); + } + }, + [appliedSinkholeFilters, buildSinkholeQueryString, ensureSinkholeLayer, isSinkholeChecked, mapReady, sinkholeScope], + ); + + const closeSinkholeDetail = useCallback(() => { + setSelectedSinkholeDetail(null); + setSelectedSinkholeNearbyPipes([]); + clearSinkholeSelectionLayer(); + clearSinkholeFocusedPipeLayer(); + + if (getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) { + setFeatureData(null); + setIsOpen(false); + } + }, [clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, featureData]); + + const loadSinkholeDetail = useCallback( + async ( + acdntNo: string, + focusMap = true, + fallbackPoint?: { x: number; y: number } | null, + fallbackDetail?: Partial | null, + ) => { + if (!acdntNo) return; + + try { + const hasFallbackCoordinate = + (fallbackDetail?.x != null && fallbackDetail?.y != null) || + (fallbackPoint != null && + Number.isFinite(fallbackPoint.x) && + Number.isFinite(fallbackPoint.y)); + const fallbackCoordStatus = String( + fallbackDetail?.coordStatus ?? fallbackDetail?.geocodeStatus ?? "", + ) + .trim() + .toUpperCase(); + + if ( + fallbackDetail && + !hasFallbackCoordinate && + fallbackCoordStatus && + fallbackCoordStatus !== "SUCCESS" + ) { + const noCoordDetail: SinkholeDetail = { + acdntNo, + ctpvNm: String(fallbackDetail.ctpvNm ?? ""), + sggNm: String(fallbackDetail.sggNm ?? ""), + emdNm: String(fallbackDetail.emdNm ?? ""), + daddr: String(fallbackDetail.daddr ?? ""), + fullAddress: String(fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? ""), + ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""), + restoreStatus: String(fallbackDetail.restoreStatus ?? ""), + restoreCompletedYmd: String(fallbackDetail.restoreCompletedYmd ?? ""), + coordStatus: String(fallbackDetail.coordStatus ?? fallbackDetail.geocodeStatus ?? ""), + geocodeStatus: String(fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? ""), + geocodeSource: String(fallbackDetail.geocodeSource ?? ""), + geocodeErrorMessage: String(fallbackDetail.geocodeErrorMessage ?? ""), + latitude: fallbackDetail.latitude ?? null, + longitude: fallbackDetail.longitude ?? null, + x: fallbackDetail.x ?? null, + y: fallbackDetail.y ?? null, + sizeWidth: fallbackDetail.sizeWidth ?? null, + sizeLength: fallbackDetail.sizeLength ?? null, + sizeDepth: fallbackDetail.sizeDepth ?? null, + detailCause: fallbackDetail.detailCause ?? null, + deathCount: fallbackDetail.deathCount ?? null, + injuryCount: fallbackDetail.injuryCount ?? null, + vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null, + }; + + setSelectedSinkholeDetail(noCoordDetail); + setSelectedSinkholeNearbyPipes([]); + renderSelectedSinkhole(null, sinkholeRadiusMeters); + setFeatureData({ + lyrTypeNm: SINKHOLE_LAYER_TYPE, + acdntNo: noCoordDetail.acdntNo, + }); + setIsOpen(true); + setIsSearchModalOpen(false); + return; + } + + let detail: SinkholeDetail; + try { + const detailResponse = await getRawJson( + `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}`, + ); + detail = ((detailResponse?.data ?? detailResponse) as SinkholeDetail); + } catch (detailError) { + if (!fallbackDetail) { + throw detailError; + } + console.warn("지반침하 상세 API 실패, 목록 데이터로 대체합니다.", detailError); + detail = { + acdntNo, + ctpvNm: String(fallbackDetail.ctpvNm ?? ""), + sggNm: String(fallbackDetail.sggNm ?? ""), + emdNm: String(fallbackDetail.emdNm ?? ""), + daddr: String(fallbackDetail.daddr ?? ""), + fullAddress: String(fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? ""), + ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""), + restoreStatus: String(fallbackDetail.restoreStatus ?? ""), + restoreCompletedYmd: String(fallbackDetail.restoreCompletedYmd ?? ""), + coordStatus: String(fallbackDetail.coordStatus ?? ""), + geocodeStatus: String(fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? ""), + geocodeSource: String(fallbackDetail.geocodeSource ?? ""), + geocodeErrorMessage: String(fallbackDetail.geocodeErrorMessage ?? ""), + latitude: fallbackDetail.latitude ?? null, + longitude: fallbackDetail.longitude ?? null, + x: fallbackDetail.x ?? null, + y: fallbackDetail.y ?? null, + sizeWidth: fallbackDetail.sizeWidth ?? null, + sizeLength: fallbackDetail.sizeLength ?? null, + sizeDepth: fallbackDetail.sizeDepth ?? null, + detailCause: fallbackDetail.detailCause ?? null, + deathCount: fallbackDetail.deathCount ?? null, + injuryCount: fallbackDetail.injuryCount ?? null, + vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null, + }; + } + const resolvedPoint = + detail.x != null && detail.y != null + ? { x: detail.x, y: detail.y } + : fallbackPoint && Number.isFinite(fallbackPoint.x) && Number.isFinite(fallbackPoint.y) + ? fallbackPoint + : null; + + let nearbyPipes: SinkholeNearbyPipe[] = []; + try { + const nearbyRawResponse = resolvedPoint + ? await getRawJson( + `/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${resolvedPoint.x}&y=${resolvedPoint.y}&radiusMeters=${sinkholeRadiusMeters}`, + ) + : await getRawJson( + `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}/nearbyPipes?radiusMeters=${sinkholeRadiusMeters}`, + ); + const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse; + nearbyPipes = Array.isArray(nearbyResponse?.items) + ? (nearbyResponse.items as SinkholeNearbyPipe[]) + : []; + } catch (nearbyError) { + console.warn("지반침하 반경 내 관로 조회 실패, 상세는 계속 표시합니다.", nearbyError); + } + const normalizedDetail = resolvedPoint + ? { + ...detail, + x: resolvedPoint.x, + y: resolvedPoint.y, + coordStatus: "SUCCESS", + } + : detail; + + setSelectedSinkholeDetail(normalizedDetail); + setSelectedSinkholeNearbyPipes(nearbyPipes); + renderSelectedSinkhole(normalizedDetail, sinkholeRadiusMeters); + setFeatureData({ + lyrTypeNm: SINKHOLE_LAYER_TYPE, + acdntNo: normalizedDetail.acdntNo, + }); + setIsOpen(true); + setIsSearchModalOpen(false); + + if (focusMap && normalizedDetail.x != null && normalizedDetail.y != null) { + focusSinkholeOnMap(normalizedDetail, sinkholeRadiusMeters); + } + } catch (error) { + console.error("지반침하 상세 조회 중 오류:", error); + alert("지반침하 상세 정보를 불러오지 못했습니다."); + } + }, + [focusSinkholeOnMap, renderSelectedSinkhole, setIsSearchModalOpen, sinkholeRadiusMeters], + ); + + const applySinkholeRadius = useCallback( + async (radiusMeters: number) => { + setSinkholeRadiusMeters(radiusMeters); + if (!selectedSinkholeDetail?.acdntNo) { + return; + } + + const selectedPoint = + selectedSinkholeDetail.x != null && selectedSinkholeDetail.y != null + ? { x: Number(selectedSinkholeDetail.x), y: Number(selectedSinkholeDetail.y) } + : null; + + try { + const nearbyRawResponse = selectedPoint && Number.isFinite(selectedPoint.x) && Number.isFinite(selectedPoint.y) + ? await getRawJson( + `/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${selectedPoint.x}&y=${selectedPoint.y}&radiusMeters=${radiusMeters}`, + ) + : await getRawJson( + `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`, + ); + const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse; + setSelectedSinkholeNearbyPipes( + Array.isArray(nearbyResponse?.items) ? (nearbyResponse.items as SinkholeNearbyPipe[]) : [], + ); + renderSelectedSinkhole(selectedSinkholeDetail, radiusMeters); + focusSinkholeOnMap(selectedSinkholeDetail, radiusMeters); + } catch (error) { + console.error("지반침하 반경 적용 중 오류:", error); + alert("반경 내 하수관로 정보를 다시 조회하지 못했습니다."); + throw error; + } + }, + [focusSinkholeOnMap, renderSelectedSinkhole, selectedSinkholeDetail], + ); + + const focusSinkholeNearbyPipe = useCallback(async (pipe: SinkholeNearbyPipe) => { + const fid = String(pipe?.fid ?? "").trim(); + if (!fid) { + alert("표시할 관로 식별값이 없습니다."); + return; + } + + const activeSinkholeTab = rightPanelTabs.find( + (tab) => tab.id === activeRightPanelTabId && tab.category === "sinkhole", + ); + pipeParentTabIdRef.current = activeSinkholeTab?.id ?? null; + + try { + const layerTypeName = String(pipe?.lyrTypeNm ?? "tb_ms_swage_ppl"); + const storageNm = String(GisApp.Module.layer?.storageNm ?? "").trim(); + if (!storageNm) throw new Error("GeoServer 저장소 이름을 확인할 수 없습니다."); + const params = new URLSearchParams({ + SERVICE: "WFS", + VERSION: "1.1.0", + REQUEST: "GetFeature", + TYPENAME: `${storageNm}${layerTypeName}`, + SRSNAME: GisApp.TO_PROJ, + OUTPUTFORMAT: "application/json", + FEATUREID: fid, + }); + const response = await getRawJson(`${GisApp.GEOSERVER_URL}?${params.toString()}`); + const featureCollection = getFeatureCollection(response); + const features = new GeoJSON().readFeatures(featureCollection, { + dataProjection: GisApp.TO_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + + const firstFeature = features[0]; + if (firstFeature) { + syncRightPanelFeatureSelectionOnMap(firstFeature); + setFeatureData(firstFeature); + setIsOpen(true); + setIsSearchModalOpen(false); + } + } catch (error) { + console.error("지반침하 반경 내 관로 지도 표시 실패:", error); + alert("선택한 관로를 지도에서 표시하지 못했습니다."); + } + }, [ + activeRightPanelTabId, + rightPanelTabs, + setIsSearchModalOpen, + syncRightPanelFeatureSelectionOnMap, + ]); + + const buildRightPanelTab = useCallback( + (data: any): RightPanelTab | null => { + if (!data) return null; + + const layerTypeName = getSelectedFeatureLayerTypeName(data); + const now = Date.now(); + + if (layerTypeName === SINKHOLE_LAYER_TYPE) { + const detail = selectedSinkholeDetail; + const acdntNo = String(detail?.acdntNo ?? getSinkholeAccidentId(data) ?? "").trim(); + if (!acdntNo) return null; + + return { + id: `sinkhole:${acdntNo}`, + kind: "sinkhole", + category: "sinkhole", + displayLabel: `지반침하(${acdntNo})`, + parentTabId: null, + featureData: { + lyrTypeNm: SINKHOLE_LAYER_TYPE, + acdntNo, + }, + sinkholeDetail: detail, + nearbyPipes: selectedSinkholeNearbyPipes, + lastActiveAt: now, + }; + } + + if (gisEditDetailChangeId) { + return { + id: `gisEdit:${gisEditDetailChangeId}`, + kind: "gisEdit", + category: "gisEdit", + displayLabel: "관로편집", + parentTabId: null, + featureData: data, + gisEditChangeId: gisEditDetailChangeId, + lastActiveAt: now, + }; + } + + const category = getRightPanelTabCategory(data); + const featureId = getRightPanelFeatureIdentifier(data) || `${layerTypeName || "feature"}:${now}`; + // Direct map selections accumulate per pipe; only a pipe with a sinkhole parent replaces the linked slot. + const pipeParentTabId = category === "pipe" ? pipeParentTabIdRef.current : null; + return { + id: + category === "pipe" && pipeParentTabId + ? RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID + : `feature:${layerTypeName || "unknown"}:${featureId}`, + kind: "feature", + category, + displayLabel: getRightPanelFeatureDisplayLabel(data, category), + parentTabId: pipeParentTabId, + featureData: data, + lastActiveAt: now, + }; + }, + [gisEditDetailChangeId, selectedSinkholeDetail, selectedSinkholeNearbyPipes], + ); + + const upsertRightPanelTab = useCallback((tab: RightPanelTab) => { + setRightPanelTabs((currentTabs) => { + const nextTab = { ...tab, lastActiveAt: Date.now() }; + const existingIndex = currentTabs.findIndex((item) => item.id === tab.id); + const mergedTabs = existingIndex < 0 + ? [...currentTabs, nextTab] + : currentTabs.map((item, index) => (index === existingIndex ? { ...item, ...nextTab } : item)); + + if (mergedTabs.length <= RIGHT_PANEL_TAB_LIMIT) return mergedTabs; + + const overflowCandidate = findRightPanelTabOverflowCandidate( + mergedTabs, + nextTab.id, + ); + if (!overflowCandidate) return mergedTabs.slice(-RIGHT_PANEL_TAB_LIMIT); + + return removeRightPanelTabGroup(mergedTabs, overflowCandidate.id); + }); + setActiveRightPanelTabId(tab.id); + }, []); + + const activateRightPanelTab = useCallback( + (tabId: string) => { + const targetTab = rightPanelTabs.find((tab) => tab.id === tabId); + if (!targetTab) return; + + setRightPanelTabs((currentTabs) => + currentTabs.map((tab) => (tab.id === tabId ? { ...tab, lastActiveAt: Date.now() } : tab)), + ); + setActiveRightPanelTabId(tabId); + if (targetTab.category === "pipe") { + pipeParentTabIdRef.current = targetTab.parentTabId ?? null; + } + + if (targetTab.kind === "sinkhole") { + const detail = targetTab.sinkholeDetail ?? null; + setSelectedSinkholeDetail(detail); + setSelectedSinkholeNearbyPipes(targetTab.nearbyPipes ?? []); + setGisEditDetailChangeId(null); + syncRightPanelFeatureSelectionOnMap(targetTab.featureData); + if (detail) { + renderSelectedSinkhole(detail, sinkholeRadiusMeters); + } + } else if (targetTab.kind === "gisEdit") { + setGisEditDetailChangeId(targetTab.gisEditChangeId ?? null); + } else { + setGisEditDetailChangeId(null); + syncRightPanelFeatureSelectionOnMap(targetTab.featureData); + } + + setFeatureData(targetTab.featureData); + setIsOpen(true); + setIsSearchModalOpen(false); + }, + [ + renderSelectedSinkhole, + rightPanelTabs, + setIsSearchModalOpen, + sinkholeRadiusMeters, + syncRightPanelFeatureSelectionOnMap, + ], + ); + + const closeRightPanelTab = useCallback( + (tabId: string | null) => { + if (!tabId) return; + const closingTab = rightPanelTabs.find((tab) => tab.id === tabId); + const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId); + + if (closingTab?.kind === "sinkhole") { + setSelectedSinkholeDetail(null); + setSelectedSinkholeNearbyPipes([]); + clearSinkholeSelectionLayer(); + } + if (closingTab?.kind === "feature") { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + clearSinkholeFocusedPipeLayer(); + } + if (closingTab?.kind === "gisEdit") { + setGisEditDetailChangeId(null); + } + if (closingTab?.category === "pipe" || pipeParentTabIdRef.current === tabId) { + pipeParentTabIdRef.current = null; + } + + setRightPanelTabs(remainingTabs); + + if (remainingTabs.length === 0) { + setActiveRightPanelTabId(null); + pipeParentTabIdRef.current = null; + setFeatureData(null); + setIsOpen(false); + return; + } + + const parentFallbackTab = + closingTab?.category === "pipe" && closingTab.parentTabId + ? remainingTabs.find((tab) => tab.id === closingTab.parentTabId) + : null; + const fallbackTab = + parentFallbackTab ?? [...remainingTabs].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0]!; + activateRightPanelTab(fallbackTab.id); + }, + [activateRightPanelTab, clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, rightPanelTabs], + ); + + // 선택 피처가 없는 지도 본체 클릭을 기존 지반침하 상세 닫기 흐름과 연결한다. + const handleSinkholeMapBlankClick = useCallback(() => { + if (gisEdit.isEditMode || !selectedSinkholeDetail?.acdntNo) return; + + const sinkholeTabId = `sinkhole:${selectedSinkholeDetail.acdntNo}`; + const hasSinkholeTab = rightPanelTabs.some((tab) => tab.id === sinkholeTabId); + + if (hasSinkholeTab && activeRightPanelTabId === sinkholeTabId) { + closeRightPanelTab(sinkholeTabId); + } else { + // 비활성 지반침하 탭은 현재 활성 탭을 바꾸지 않고 종속 관로와 함께 제거한다. + setRightPanelTabs((currentTabs) => + removeRightPanelTabGroup(currentTabs, sinkholeTabId), + ); + setSelectedSinkholeDetail(null); + setSelectedSinkholeNearbyPipes([]); + clearSinkholeSelectionLayer(); + if (pipeParentTabIdRef.current === sinkholeTabId) { + pipeParentTabIdRef.current = null; + } + if (!hasSinkholeTab && getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) { + setFeatureData(null); + setIsOpen(false); + } + } + + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + clearSinkholeFocusedPipeLayer(); + }, [ + activeRightPanelTabId, + clearSinkholeFocusedPipeLayer, + clearSinkholeSelectionLayer, + closeRightPanelTab, + featureData, + gisEdit.isEditMode, + rightPanelTabs, + selectedSinkholeDetail, + ]); + + useEffect(() => { + if (!isOpen || !featureData) return; + const nextTab = buildRightPanelTab(featureData); + if (nextTab) upsertRightPanelTab(nextTab); + }, [buildRightPanelTab, featureData, isOpen, upsertRightPanelTab]); + + useEffect(() => { + if (isOpen) return; + pipeParentTabIdRef.current = null; + setRightPanelTabs([]); + setActiveRightPanelTabId(null); + }, [isOpen]); + + const openSinkholePopupWindow = useCallback(() => { + const effectiveFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); + const popup = openWindowPopup(SINKHOLE_POPUP_KEY as WindowPopupKey, effectiveFilters as Record); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + + setIsSinkholePopupFilterActive(true); + setAppliedSinkholeFilters(effectiveFilters); + setSinkholeSelectionVersion((version) => version + 1); + + if (sinkholePopupCloseWatcherRef.current) { + window.clearInterval(sinkholePopupCloseWatcherRef.current); + sinkholePopupCloseWatcherRef.current = null; + } + sinkholePopupWindowRef.current = popup; + sinkholePopupCloseWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + if (sinkholePopupCloseWatcherRef.current) { + window.clearInterval(sinkholePopupCloseWatcherRef.current); + sinkholePopupCloseWatcherRef.current = null; + } + sinkholePopupWindowRef.current = null; + setIsSinkholePopupFilterActive(false); + setAppliedSinkholeFilters(normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), sinkholeScope)); + setSinkholeSelectionVersion((version) => version + 1); + }, 300); + return popup; + }, [appliedSinkholeFilters, sinkholeScope, sinkholeYearRange]); + + useEffect(() => () => { + if (sinkholePopupCloseWatcherRef.current) { + window.clearInterval(sinkholePopupCloseWatcherRef.current); + sinkholePopupCloseWatcherRef.current = null; + } + }, []); + const handleAgedPipeRowDoubleClick = useCallback( async (row: AgedPipeRow) => { try { @@ -1078,6 +5072,50 @@ const Map = () => { return () => window.removeEventListener("message", handleAgedPipePopupMessage); }, [agedPipeTotalCount, handleAgedPipeRowDoubleClick, handlePipeAnalysisRowSelect]); + useEffect(() => { + const handleSinkholePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if (event.data?.type === SINKHOLE_FILTER_SYNC_MESSAGE && event.data?.filters) { + const normalized = normalizeSinkholeFilters(event.data.filters as SinkholeFilters, sinkholeScope); + setIsSinkholePopupFilterActive(true); + setAppliedSinkholeFilters(normalized); + setSinkholeSelectionVersion((version) => version + 1); + return; + } + + if (event.data?.type === SINKHOLE_ROW_SELECT_MESSAGE && event.data?.row) { + const row = event.data.row as SinkholeListItem; + const fallbackPoint = + row.x != null && row.y != null + ? { x: Number(row.x), y: Number(row.y) } + : null; + void loadSinkholeDetail(String(row.acdntNo ?? ""), true, fallbackPoint, row); + return; + } + + if (event.data?.type === SINKHOLE_SYNC_COMPLETED_MESSAGE && event.data?.payload) { + setLastSinkholeSyncSummary(event.data.payload as SinkholeSyncSummary); + setSinkholeSelectionVersion((version) => version + 1); + } + }; + + window.addEventListener("message", handleSinkholePopupMessage); + return () => window.removeEventListener("message", handleSinkholePopupMessage); + }, [loadSinkholeDetail, sinkholeScope]); + + useEffect(() => { + const handleFloodHistoryPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type !== FLOOD_HISTORY_ROW_SELECT_MESSAGE || !event.data?.row) return; + + handleFloodHistoryRowSelect(event.data.row as FloodHistoryItem); + }; + + window.addEventListener("message", handleFloodHistoryPopupMessage); + return () => window.removeEventListener("message", handleFloodHistoryPopupMessage); + }, [handleFloodHistoryRowSelect]); + // WaterDepth 표시 여부를 결정하는 함수 const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { const waterDepthLayers = checkedNodesData.filter(node => @@ -1085,12 +5123,14 @@ const Map = () => { ); const hasWaterDepthLayer = waterDepthLayers.length > 0; - setShowWaterDepth(hasWaterDepthLayer); + setIsWaterDepthLayerActive(hasWaterDepthLayer); }, []); // 체크된 레이어 변경 처리 함수 const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => { checkedNodesDataRef.current = checkedNodesData; + setCheckedLayerLegendVersion((version) => version + 1); + setSwagePrcsFcltLayerMode(getSwagePrcsFcltLayerMode(checkedNodesData)); // 유하방향은 일반 setLayerOnMap 대상이 아니므로 체크 노드 여부를 별도로 추적한다. const flowDirectionNode = checkedNodesData.find((node) => @@ -1102,12 +5142,22 @@ const Map = () => { setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo)); setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo); if (nextFlowDirectionLayerInfo) { - clearFlowDirectionFeatureCache(); + const map = gisMaps.current; + const zoom = map?.getView?.()?.getZoom?.() ?? 0; + const nextUsageStyleMap = extractCheckedPipeUsageStyles(checkedNodesData, zoom); + const requestUsageCodes = retainFlowDirectionUsageCodes(Object.keys(nextUsageStyleMap)); + flowDirectionSelectionRequestUsageCodesRef.current = requestUsageCodes; + if (requestUsageCodes.length === 0) { + flowDirectionRequestSeqRef.current += 1; + flowDirectionAbortControllerRef.current?.abort(); + flowDirectionAbortControllerRef.current = null; + } setFlowDirectionSelectionVersion((version) => version + 1); } if (!nextFlowDirectionLayerInfo) { // 해제 직후 pending API 응답이 늦게 도착해도 renderFlowDirectionLayer가 실행되지 않도록 순번을 무효화한다. + flowDirectionSelectionRequestUsageCodesRef.current = null; flowDirectionRequestSeqRef.current += 1; flowDirectionAbortControllerRef.current?.abort(); flowDirectionAbortControllerRef.current = null; @@ -1116,8 +5166,57 @@ const Map = () => { } handleWaterDepthVisibility(checkedIds, checkedNodesData); - setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node))); - }, [clearFlowDirectionFeatureCache, handleWaterDepthVisibility, removeFlowDirectionLayer]); + const agedPipeChecked = checkedNodesData.some((node) => isAgedPipeLayerNode(node)); + const waterDepthChecked = checkedNodesData.some((node) => + WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")), + ); + setIsAgedPipeChecked(agedPipeChecked); + const floodHistoryChecked = checkedNodesData.some((node) => isFloodHistoryNode(node)); + setIsFloodHistoryChecked(floodHistoryChecked); + if (!floodHistoryChecked) { + clearFloodHistorySelection(); + } + const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node)); + setIsSinkholeChecked(sinkholeChecked); + setActiveLayerSettingOrder((currentOrder) => { + const activeKeys = [ + waterDepthChecked ? "waterDepthLegend" : null, + sinkholeChecked ? "sinkhole" : null, + agedPipeChecked ? "agedPipe" : null, + ].filter((key): key is string => Boolean(key)); + + return [ + ...currentOrder.filter((key) => activeKeys.includes(key)), + ...activeKeys.filter((key) => !currentOrder.includes(key)), + ]; + }); + if (sinkholeChecked) { + setSinkholeSelectionVersion((version) => version + 1); + } + if (!sinkholeChecked) { + removeSinkholeLayer(); + closeSinkholeDetail(); + } + }, [clearFloodHistorySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]); + + const waterDepthLegendItems = useMemo( + () => extractWaterDepthLegendItems(checkedNodesDataRef.current ?? []), + [checkedLayerLegendVersion], + ); + + + const applySinkholeYearRange = useCallback( + (nextRange: SinkholeYearRange) => { + const normalizedRange = normalizeSinkholeYearRange(nextRange, sinkholeYearRange); + setSinkholeYearRange(normalizedRange); + setIsSinkholePopupFilterActive(false); + setAppliedSinkholeFilters( + normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(normalizedRange, sinkholeScope), sinkholeScope), + ); + setSinkholeSelectionVersion((version) => version + 1); + }, + [sinkholeScope, sinkholeYearRange], + ); const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { @@ -1125,26 +5224,147 @@ const Map = () => { openAgedPipeList(); return false; } + if (isSinkholeRootNode(node)) { + openSinkholePopupWindow(); + return false; + } + if (isFloodHistoryNode(node)) { + openFloodHistoryPopupWindow(); + return false; + } return true; }, - [openAgedPipeList], + [openAgedPipeList, openFloodHistoryPopupWindow, openSinkholePopupWindow], ); - const inlineLayerSettingView = useCallback( - (node: any) => { - if (!isAgedPipeChecked || !isAgedPipeLayerNode(node)) return null; + // 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다. + const activeLayerSettingItems = useMemo<{ key: string; label: string; content: ReactNode }[]>(() => { + const nextItems: { key: string; label: string; content: ReactNode; order: number }[] = []; + const resolveSettingOrder = (key: string, fallback: number) => { + const index = activeLayerSettingOrder.indexOf(key); + return index >= 0 ? index : fallback; + }; - return ( - - ); - }, - [draftAgedPipeFilters, handleApplyAgedPipeFilters, isAgedPipeChecked], - ); + if (isSinkholeChecked) { + nextItems.push({ + key: "sinkhole", + label: "지반침하정보", + order: resolveSettingOrder("sinkhole", 9998), + content: ( + + ), + }); + } + + if (isAgedPipeChecked) { + nextItems.push({ + key: "agedPipe", + label: "노후관로", + order: resolveSettingOrder("agedPipe", 9999), + content: ( + + ), + }); + } + + if (isWaterDepthLayerActive) { + nextItems.push({ + key: "waterDepthLegend", + label: "침수심 범례", + order: resolveSettingOrder("waterDepthLegend", 9997), + content: ( +
+
+
+
+ 침수심 범례 +
+
+ {waterDepthLegendItems.map((item) => ( +
+ + {item.label} +
+ ))} +
+
+
+
+ ), + }); + } + + return nextItems + .sort((a, b) => a.order - b.order) + .map(({ key, label, content }) => ({ key, label, content })); + }, [ + activeLayerSettingOrder, + applySinkholeRadius, + applySinkholeYearRange, + draftAgedPipeFilters, + handleApplyAgedPipeFilters, + isAgedPipeChecked, + isSinkholeChecked, + isWaterDepthLayerActive, + lastSinkholeSyncSummary, + sinkholeRadiusMeters, + sinkholeYearRange, + userInfo?.adminRoleYn, + waterDepthLegendItems, + ]); + + // 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다. + const ActiveLayerSettingView = useCallback(() => { + if (activeLayerSettingItems.length === 0) return null; + + return ( +
+
+
+ 선택 레이어 설정 + {activeLayerSettingItems.length}건 +
+
+ {activeLayerSettingItems.map((item) => ( + + {item.label} + + ))} +
+
+
+
+ {activeLayerSettingItems.map((item) => ( + {item.content} + ))} +
+
+
+ ); + }, [activeLayerSettingItems]); useEffect(() => { if (!mapReady || !gisMaps.current) return; @@ -1193,20 +5413,967 @@ const Map = () => { const popup = openWindowPopup(popupKey); if (!popup) { alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); - return; + return null; } if (popupKey === "pipeAnalysisPopup") { pipeAnalysisPopupWindowRef.current = popup; } + + if (popupKey === "flowRiskPopup") { + clearWaterRiskPopupCloseWatcher(); + waterRiskPopupWindowRef.current = popup; + waterRiskPopupCloseWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + clearWaterRiskPopupCloseWatcher(); + waterRiskPopupWindowRef.current = null; + stopWaterRiskSettingEdit(); + }, 300); + } + + return popup; + }, [clearWaterRiskPopupCloseWatcher, stopWaterRiskSettingEdit]); + + const openGisEditSessionPopup = useCallback(() => { + const existingPopup = gisEditSessionPopupWindowRef.current; + if (existingPopup && !existingPopup.closed) { + existingPopup.focus(); + window.focus?.(); + return existingPopup; + } + + const popup = openWindowPopup("gisEditSessionPopup"); + if (!popup) return null; + + gisEditSessionPopupWindowRef.current = popup; + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + gisEditSessionPopupCloseWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + }, 300); + window.focus?.(); + return popup; }, []); + const ensureGisEditPreviewLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) { + const layers = map.getLayers?.().getArray?.() ?? []; + if (!layers.includes(gisEditPreviewLayerRef.current)) { + map.addLayer(gisEditPreviewLayerRef.current); + } + gisEditPreviewLayerRef.current.setZIndex?.(10001); + return gisEditPreviewSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + style: createGisEditPreviewStyle, + zIndex: 10001, + updateWhileAnimating: true, + updateWhileInteracting: true, + }); + + layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID); + layer.set("lyrNm", "GIS 편집 미리보기"); + layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID); + // Preview selection is handled by map.forEachFeatureAtPixel; keep it out of the common Select styler. + layer.set("clickPsblYn", "N"); + layer.setVisible?.(true); + + map.addLayer(layer); + gisEditPreviewLayerRef.current = layer; + gisEditPreviewSourceRef.current = source; + return source; + }, []); + + const clearGisEditPreviewLayer = useCallback(() => { + gisEditPreviewSourceRef.current?.clear(); + setGisEditPreviewCursor(""); + }, [setGisEditPreviewCursor]); + + const ensureGisEditPipeSnapSource = useCallback(() => { + if (gisEditPipeSnapSourceRef.current) { + return gisEditPipeSnapSourceRef.current; + } + const source = new VectorSource(); + gisEditPipeSnapSourceRef.current = source; + return source; + }, []); + + const clearGisEditPipeSnapSource = useCallback(() => { + gisEditPipeSnapAbortControllerRef.current?.abort(); + gisEditPipeSnapAbortControllerRef.current = null; + gisEditPipeSnapSourceRef.current?.clear(); + }, []); + + const buildGisEditPipeSnapPreviewFeatures = useCallback((excludedChangeId?: string | null) => { + const normalizedExcludedChangeId = String(excludedChangeId ?? "").trim(); + const geoJson = new GeoJSON(); + + return gisEdit.changes + .filter((change) => change.layerId === "tb_ms_swage_ppl") + .filter((change) => !change.attributeOnly && change.editType !== "DELETE" && Boolean(change.afterGeometry)) + .filter((change) => change.changeId !== normalizedExcludedChangeId) + .map((change) => { + const feature = geoJson.readFeature( + { + type: "Feature", + geometry: change.afterGeometry, + properties: { + fid: change.fid ?? change.featureRef?.fid ?? change.objectId ?? change.changeId, + layerId: change.layerId, + changeId: change.changeId, + }, + }, + { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }, + ); + feature.setId(`gis-edit-snap:${change.changeId}`); + return feature; + }); + }, [gisEdit.changes]); + + const loadGisEditPipeSnapSource = useCallback(async (options?: { excludedFid?: string | null; excludedChangeId?: string | null }) => { + const map = gisMaps.current; + const source = ensureGisEditPipeSnapSource(); + if (!map || !source) return; + + const size = map.getSize?.(); + const view = map.getView?.(); + if (!size || !view?.calculateExtent) return; + + const normalizedExcludedFid = normalizeGisEditFid(options?.excludedFid ?? ""); + const normalizedExcludedChangeId = String(options?.excludedChangeId ?? "").trim(); + const hiddenPipeFidSet = new Set( + gisEdit.changes + .filter((change) => change.layerId === "tb_ms_swage_ppl") + .filter((change) => !change.attributeOnly && change.editType !== "DELETE") + .map((change) => normalizeGisEditFid(change.fid ?? change.featureRef?.fid ?? change.objectId)) + .filter((fid) => /^\d+$/.test(fid)), + ); + if (/^\d+$/.test(normalizedExcludedFid)) { + hiddenPipeFidSet.add(normalizedExcludedFid); + } + + const previewSnapFeatures = buildGisEditPipeSnapPreviewFeatures(normalizedExcludedChangeId); + source.clear(); + if (previewSnapFeatures.length > 0) { + source.addFeatures(previewSnapFeatures); + } + + const screenExtent = view.calculateExtent(size); + const requestExtent = expandGisEditPipeSnapExtent(screenExtent); + const [minX, minY, maxX, maxY] = requestExtent; + const bboxFilter = `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`; + const cqlFilter = bboxFilter; + + const requestSeq = gisEditPipeSnapRequestSeqRef.current + 1; + gisEditPipeSnapRequestSeqRef.current = requestSeq; + gisEditPipeSnapAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + gisEditPipeSnapAbortControllerRef.current = abortController; + + try { + const layerModule = GisApp.Module.layer as any; + const storageName = String(layerModule?.storageNm ?? "hasudo:"); + const params = new URLSearchParams({ + SERVICE: "WFS", + VERSION: "1.1.0", + REQUEST: "GetFeature", + TYPENAME: `${storageName}tb_ms_swage_ppl`, + SRSNAME: GisApp.TO_PROJ, + OUTPUTFORMAT: "application/json", + CQL_FILTER: cqlFilter, + }); + const response = await getRawJson(`${GisApp.GEOSERVER_URL}?${params.toString()}`, { + signal: abortController.signal, + }); + if (requestSeq !== gisEditPipeSnapRequestSeqRef.current) return; + if (abortController.signal.aborted) return; + + const featureCollection = getFeatureCollection(response); + const features = new GeoJSON().readFeatures(featureCollection, { + dataProjection: GisApp.TO_PROJ, + featureProjection: GisApp.TO_PROJ, + }).filter((feature: any) => !hiddenPipeFidSet.has(getGisEditFeatureFid(feature))); + + source.clear(); + source.addFeatures(features); + if (previewSnapFeatures.length > 0) { + source.addFeatures(previewSnapFeatures); + } + } catch (error) { + if ((error as Error)?.name === "AbortError") return; + console.error("GIS ?? ???? ?? source ?? ??:", error); + source.clear(); + if (previewSnapFeatures.length > 0) { + source.addFeatures(previewSnapFeatures); + } + } finally { + if (gisEditPipeSnapAbortControllerRef.current === abortController) { + gisEditPipeSnapAbortControllerRef.current = null; + } + } + }, [buildGisEditPipeSnapPreviewFeatures, ensureGisEditPipeSnapSource, gisEdit.changes]); + + const restoreGisEditOriginalPipeMask = useCallback(() => { + gisEditOriginalLayerFilterRef.current.forEach((originalFilter, layer) => { + const source = layer?.getSource?.(); + const params = { ...(source?.getParams?.() ?? {}) }; + if (originalFilter) { + params.CQL_FILTER = originalFilter; + } else { + delete params.CQL_FILTER; + } + params._t = Date.now(); + source?.updateParams?.(params); + source?.refresh?.(); + source?.changed?.(); + layer?.changed?.(); + }); + gisEditOriginalLayerFilterRef.current.clear(); + + gisEditOriginalFeatureStyleRef.current.forEach((originalStyle, feature) => { + feature?.setStyle?.(originalStyle); + feature?.changed?.(); + }); + gisEditOriginalFeatureStyleRef.current.clear(); + gisMaps.current?.renderSync?.(); + }, []); + + const applyGisEditOriginalPipeMask = useCallback((hiddenFids: string[]) => { + const hiddenFidSet = new Set( + hiddenFids + .map((fid) => normalizeGisEditFid(fid)) + .filter(Boolean), + ); + + if (hiddenFidSet.size === 0) { + restoreGisEditOriginalPipeMask(); + return; + } + + const map = gisMaps.current; + if (!map) return; + + const filter = buildGisEditHiddenPipeFilter(Array.from(hiddenFidSet)); + map.getAllLayers?.().forEach((layer: any) => { + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (layerTypeName !== "tb_ms_swage_ppl") return; + + const source = layer.getSource?.(); + if (source?.updateParams && filter) { + const params = { ...(source.getParams?.() ?? {}) }; + if (!gisEditOriginalLayerFilterRef.current.has(layer)) { + gisEditOriginalLayerFilterRef.current.set(layer, String(params.CQL_FILTER ?? "").trim() || null); + } + const originalFilter = gisEditOriginalLayerFilterRef.current.get(layer); + params.CQL_FILTER = originalFilter ? `(${originalFilter}) AND (${filter})` : filter; + params._t = Date.now(); + source.updateParams(params); + source.refresh?.(); + source.changed?.(); + layer.changed?.(); + } + + source?.getFeatures?.().forEach((feature: any) => { + const fid = getGisEditFeatureFid(feature); + const shouldHide = hiddenFidSet.has(fid); + if (shouldHide && !gisEditOriginalFeatureStyleRef.current.has(feature)) { + gisEditOriginalFeatureStyleRef.current.set(feature, feature.getStyle?.()); + feature.setStyle?.(() => []); + feature.changed?.(); + } + if (!shouldHide && gisEditOriginalFeatureStyleRef.current.has(feature)) { + feature.setStyle?.(gisEditOriginalFeatureStyleRef.current.get(feature)); + gisEditOriginalFeatureStyleRef.current.delete(feature); + feature.changed?.(); + } + }); + }); + + gisMaps.current?.renderSync?.(); + }, [restoreGisEditOriginalPipeMask]); + + const ensureGisEditVertexLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (gisEditVertexLayerRef.current && gisEditVertexSourceRef.current) { + return gisEditVertexSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 10005, + updateWhileAnimating: true, + updateWhileInteracting: true, + }); + + layer.set("id", GIS_EDIT_VERTEX_LAYER_ID); + layer.set("lyrNm", "GIS 편집 꼭지점"); + layer.set("lyrTyNm", GIS_EDIT_VERTEX_LAYER_ID); + layer.set("clickPsblYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(GIS_EDIT_VERTEX_LAYER_ID); + map.addLayer(layer); + gisEditVertexLayerRef.current = layer; + gisEditVertexSourceRef.current = source; + return source; + }, []); + + const clearGisEditVertexLayer = useCallback(() => { + gisEditVertexSourceRef.current?.clear(); + gisEditSelectedVertexHandleKeyRef.current = ""; + gisEditSelectedVertexMetaListRef.current = null; + }, []); + + const applySelectedVertexHandleStyle = useCallback(() => { + const selectedHandleKey = gisEditSelectedVertexHandleKeyRef.current; + const vertexSource = gisEditVertexSourceRef.current; + if (!vertexSource) return; + + vertexSource.getFeatures().forEach((feature: any) => { + const handleKind = String(feature?.get?.("handleKind") ?? "") as VertexHandleMeta["kind"]; + const handleSelectionKey = String(feature?.get?.("handleSelectionKey") ?? ""); + const isSelected = Boolean(selectedHandleKey) && handleSelectionKey === selectedHandleKey; + feature.setStyle(getGisEditVertexHandleStyle(handleKind, isSelected)); + }); + vertexSource.changed?.(); + }, []); + + const clearSelectedVertexHandle = useCallback(() => { + gisEditSelectedVertexHandleKeyRef.current = ""; + gisEditSelectedVertexMetaListRef.current = null; + applySelectedVertexHandleStyle(); + }, [applySelectedVertexHandleStyle]); + + const shouldUsePipeSnapSource = + gisEdit.isEditMode && + gisEdit.isSnapEnabled && + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl"; + + const collectGisEditSnapFeatures = useCallback((targetFeature: any) => { + const map = gisMaps.current; + const previewSource = gisEditPreviewSourceRef.current; + const selectedLayerId = gisEdit.selectedLayer?.layerId; + if (!map || !previewSource || !selectedLayerId) return [] as any[]; + + const hiddenFidSet = new Set( + gisEdit.changes + .filter((change) => change.editType === "DELETE") + .map((change) => normalizeGisEditFid(change.fid ?? change.featureRef?.fid ?? change.objectId)) + .filter(Boolean), + ); + const seen = new Set(); + const snapFeatures: any[] = []; + const targetChangeId = String(targetFeature?.get?.("changeId") ?? "").trim(); + + const registerFeature = (feature: any) => { + if (!feature || feature === targetFeature) return; + if (!feature.getGeometry?.()) return; + if (getSelectedFeatureLayerTypeName(feature) !== selectedLayerId) return; + + const changeId = String(feature.get?.("changeId") ?? "").trim(); + if (changeId && changeId === targetChangeId) return; + if (String(feature.get?.("editType") ?? "").trim() === "DELETE") return; + + const fid = getGisEditFeatureFid(feature); + if (fid && hiddenFidSet.has(fid)) return; + + const objectKey = changeId || fid || String(feature.get?.("objectId") ?? feature.getId?.() ?? ""); + const featureKey = `${selectedLayerId}:${objectKey}`; + if (!objectKey || seen.has(featureKey)) return; + + seen.add(featureKey); + snapFeatures.push(feature); + }; + + previewSource.getFeatures().forEach(registerFeature); + map.getAllLayers?.().forEach((layer: any) => { + if (layer === gisEditPreviewLayerRef.current || layer === gisEditVertexLayerRef.current) return; + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (layerTypeName !== selectedLayerId) return; + layer.getSource?.()?.getFeatures?.().forEach(registerFeature); + }); + + return snapFeatures; + }, [gisEdit.changes, gisEdit.selectedLayer?.layerId]); + + const removeGisEditDrawInteraction = useCallback(() => { + const map = gisMaps.current; + if (!map) return; + + if (gisEditDrawSnapRef.current) { + map.removeInteraction(gisEditDrawSnapRef.current); + gisEditDrawSnapRef.current = null; + } + if (gisEditDrawRef.current) { + map.removeInteraction(gisEditDrawRef.current); + gisEditDrawRef.current = null; + } + }, []); + + const removeGisEditInteractions = useCallback(() => { + const map = gisMaps.current; + if (!map) return; + + if (gisEditSnapRef.current) { + map.removeInteraction(gisEditSnapRef.current); + gisEditSnapRef.current = null; + } + if (gisEditTranslateRef.current) { + map.removeInteraction(gisEditTranslateRef.current); + gisEditTranslateRef.current = null; + } + if (gisEditModifyRef.current) { + const modifyInteractions = Array.isArray(gisEditModifyRef.current) + ? gisEditModifyRef.current + : [gisEditModifyRef.current]; + modifyInteractions.forEach((interaction: any) => { + if (interaction) { + map.removeInteraction(interaction); + } + }); + gisEditModifyRef.current = null; + } + clearGisEditVertexLayer(); + setGisEditPreviewCursor(""); + }, [clearGisEditVertexLayer, setGisEditPreviewCursor]); + + const refreshGisEditTargetLayers = useCallback((layerTypeNames: string[], focusFid?: string | null) => { + const refreshTargetNames = new Set( + (layerTypeNames.length > 0 ? layerTypeNames : ["tb_ms_swage_ppl"]) + .map((layerTypeName) => String(layerTypeName ?? "").trim()) + .filter(Boolean), + ); + const map = gisMaps.current; + map?.getAllLayers?.().forEach((layer: any) => { + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (!refreshTargetNames.has(layerTypeName)) return; + + const source = layer.getSource?.(); + source?.updateParams?.({ _t: Date.now() }); + source?.refresh?.(); + source?.changed?.(); + layer.changed?.(); + }); + + if (focusFid) { + const core = GisApp.Module.layer as any; + core?.showSearchResultsFromGeoJSON?.("tb_ms_swage_ppl", focusFid); + } + }, []); + + // GIS 편집 진입 시에는 화면 안 인라인 세션을 우선 표시하고, 윈도우 팝업은 사용자가 명시적으로 연다. + const handleToggleGisEdit = useCallback(() => { + if (gisEdit.isEditMode) { + closeGisEditSessionPopup(); + } else { + setGisEditSessionView("inline"); + } + gisEdit.toggleEditMode(); + setIsOpen(false); + }, [closeGisEditSessionPopup, gisEdit]); + + const handleOpenGisEditSessionPopup = useCallback(() => { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return; + } + setGisEditSessionView("popup"); + syncGisEditSessionPopup(); + }, [openGisEditSessionPopup, syncGisEditSessionPopup]); + + // 툴바 버튼은 현재 지도 중심 또는 다음 피처 선택을 기준으로 로컬 변경분을 만든다. + const handleSelectGisEditTool = useCallback((tool: "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE") => { + if (gisEdit.activeTool === tool) { + gisEdit.deactivateTool(); + removeGisEditDrawInteraction(); + removeGisEditInteractions(); + return; + } + + removeGisEditDrawInteraction(); + removeGisEditInteractions(); + gisEdit.activateTool(tool, gisMaps.current); + setIsOpen(false); + }, [gisEdit, removeGisEditDrawInteraction, removeGisEditInteractions]); + + const handleCloseGisEdit = useCallback(() => { + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + clearGisEditPreviewLayer(); + clearGisEditVertexLayer(); + closeGisEditSessionPopup(); + gisEdit.discardSession(); + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + }, [ + clearGisEditPreviewLayer, + clearGisEditVertexLayer, + closeGisEditSessionPopup, + gisEdit, + removeGisEditDrawInteraction, + removeGisEditInteractions, + ]); + + const handleSaveGisEdit = useCallback(async () => { + if (isGisEditSaving) return; + + const payload = gisEdit.save(gisMaps.current); + if (!payload) return; + + const invalidRequiredChange = findInvalidPipeRequiredChange(payload.changes); + if (invalidRequiredChange) { + const message = "관리기관코드, 시설관리번호, 기관관리번호, 용도는 필수 입력 항목입니다."; + alert(message); + gisEdit.announceStatus(message); + setGisEditDetailChangeId(invalidRequiredChange.changeId); + setIsOpen(true); + return; + } + + setIsGisEditSaving(true); + gisEdit.announceStatus("관로 편집 변경분을 저장 중입니다."); + try { + const response = await postRawJson("/sewerage-service/api/v1/map/gisEditSessions/save", payload); + const results = Array.isArray(response?.results) ? response.results : []; + const refreshLayerTypeNames = Array.isArray(response?.refreshLayerTypeNames) + ? response.refreshLayerTypeNames + : results.flatMap((result: any) => result?.refreshLayerTypeNames ?? []); + const focusFid = results.find((result: any) => result?.fid && result?.editType !== "DELETE")?.fid ?? null; + + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + clearGisEditPreviewLayer(); + clearGisEditVertexLayer(); + refreshGisEditTargetLayers(refreshLayerTypeNames, focusFid); + gisEdit.clearChanges(); + setGisEditDetailChangeId(null); + setIsOpen(false); + closeGisEditSessionPopup(); + gisEdit.announceStatus(response?.message ?? "하수관로 편집 저장이 완료되었습니다."); + } catch (error) { + console.error("GIS 편집 저장 실패:", error); + const apiError = error as Error & { body?: any }; + gisEdit.announceStatus(`하수관로 편집 저장 실패: ${apiError.body?.message ?? apiError.message}`); + } finally { + setIsGisEditSaving(false); + } + }, [ + clearGisEditPreviewLayer, + clearGisEditVertexLayer, + closeGisEditSessionPopup, + gisEdit, + isGisEditSaving, + refreshGisEditTargetLayers, + removeGisEditDrawInteraction, + removeGisEditInteractions, + ]); + + const handleApplyGisEditPipeBasicInfo = useCallback((changeId: string, basicInfo: GisEditPipeBasicInfo) => { + gisEdit.updateChangeBasicInfo(changeId, basicInfo); + }, [gisEdit.updateChangeBasicInfo]); + + const canSelectFeatureDuringGisEdit = useCallback((feature: any) => { + if (!gisEdit.isEditMode) return true; + if (!gisEdit.activeTool) return true; + + if ( + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" && + getSelectedFeatureLayerTypeName(feature) !== "tb_ms_swage_ppl" + ) { + gisEdit.announceStatus("하수관로를 선택해주세요."); + } + return false; + }, [gisEdit]); + + const GisEditAwareDetailView = useCallback((props: any) => { + const activeRightPanelTab = rightPanelTabs.find((tab) => tab.id === activeRightPanelTabId) ?? null; + const panelFeatureData = activeRightPanelTab?.featureData ?? props.featureData; + const currentChange = gisEditDetailChangeRef.current; + const currentFeatureData = gisEditDetailFeatureDataRef.current; + const isEditMode = gisEditIsEditModeRef.current; + const selectedLayerTypeName = getSelectedFeatureLayerTypeName(panelFeatureData); + const panelSinkholeDetail = activeRightPanelTab?.kind === "sinkhole" + ? activeRightPanelTab.sinkholeDetail ?? selectedSinkholeDetail + : selectedSinkholeDetail; + const panelSinkholeNearbyPipes = activeRightPanelTab?.kind === "sinkhole" + ? activeRightPanelTab.nearbyPipes ?? selectedSinkholeNearbyPipes + : selectedSinkholeNearbyPipes; + const tabsView = ( + + ); + const closeActiveTab = () => { + if (activeRightPanelTabId) { + closeRightPanelTab(activeRightPanelTabId); + return; + } + props.onClose?.(); + }; + + if (isEditMode && currentChange && currentChange.layerId === "tb_ms_swage_ppl" && currentChange.editType !== "DELETE") { + return ( + <> + {tabsView} + { + setGisEditDetailChangeId(null); + closeActiveTab(); + }} + onSave={handleApplyGisEditPipeBasicInfo} + /> + + ); + } + + if (selectedLayerTypeName === SINKHOLE_LAYER_TYPE && panelSinkholeDetail) { + return ( + <> + {tabsView} + + + ); + } + + return ( + <> + {tabsView} + { + setGisEditDetailChangeId(null); + closeActiveTab(); + }} + /> + + ); + }, [ + activateRightPanelTab, + activeRightPanelTabId, + closeRightPanelTab, + focusSinkholeNearbyPipe, + handleApplyGisEditPipeBasicInfo, + rightPanelTabs, + selectedSinkholeDetail, + selectedSinkholeNearbyPipes, + sinkholeRadiusMeters, + ]); + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; + + const map = gisMaps.current; + const clickKey = map.on("singleclick", (event: any) => { + const vertexHandleFeature = + gisEdit.pendingInteraction?.editType === "VERTEX_EDIT" + ? map.forEachFeatureAtPixel( + event.pixel, + (feature: any, layer: any) => + layer === gisEditVertexLayerRef.current ? feature : null, + { hitTolerance: 14 }, + ) + : null; + if (vertexHandleFeature) { + event.preventDefault?.(); + const handleKind = String(vertexHandleFeature.get?.("handleKind") ?? ""); + if (handleKind !== "vertex") { + clearSelectedVertexHandle(); + gisEdit.announceStatus("흰색 midpoint는 드래그해서 새 꼭지점을 추가할 수 있습니다. Delete 삭제 대상은 주황색 실꼭지점입니다."); + return; + } + + gisEditSelectedVertexHandleKeyRef.current = String( + vertexHandleFeature.get?.("handleSelectionKey") ?? "", + ); + gisEditSelectedVertexMetaListRef.current = + (vertexHandleFeature.get?.("handleMetaList") as VertexHandleMeta[] | undefined) ?? null; + applySelectedVertexHandleStyle(); + gisEdit.announceStatus("주황색 실꼭지점을 선택했습니다. Delete 키를 누르면 해당 꼭지점을 삭제합니다."); + return; + } + + const previewFeature = map.forEachFeatureAtPixel( + event.pixel, + (feature: any) => String(feature?.get?.("changeId") ?? "").trim() ? feature : null, + { hitTolerance: 12 }, + ); + // 편집 도구가 켜진 동안에는 공통 Select를 우회하므로 원본 WFS 피처도 여기서 직접 판정한다. + const editableSourceFeature = previewFeature + ? null + : gisEdit.activeTool && gisEdit.activeTool !== "ADD" + ? map.forEachFeatureAtPixel( + event.pixel, + (feature: any) => { + if (String(feature?.get?.("changeId") ?? "").trim()) return null; + return getSelectedFeatureLayerTypeName(feature) === gisEdit.selectedLayer?.layerId + ? feature + : null; + }, + { hitTolerance: 12 }, + ) + : null; + const selectedFeature = previewFeature ?? editableSourceFeature; + if (!selectedFeature) return; + clearSelectedVertexHandle(); + + const editSelection = gisEdit.registerFeatureSelection(selectedFeature); + if (!editSelection.handled) return; + + event.preventDefault?.(); + + if (editSelection.editType === "DELETE") { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + if (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") { + if (gisEdit.activeTool === "MOVE" && typeof editSelection.changeId === "string" && editSelection.changeId) { + gisEditMoveGripMetaRef.current = { + changeId: String(editSelection.changeId), + meta: getClosestGeometryVertexMeta(selectedFeature.getGeometry?.(), map, event.coordinate), + }; + } + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + const selectedChangeId = + typeof editSelection.changeId === "string" ? editSelection.changeId : ""; + const selectedChange = gisEdit.changes.find((change) => change.changeId === selectedChangeId); + const canOpenPipeDetail = + selectedChangeId && + editSelection.editType !== "DELETE"; + + if (canOpenPipeDetail) { + openGisEditPipeDetail(selectedChangeId); + setFeatureData( + selectedChange?.editType === "ADD" + ? { + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: "", + mngInstCd: "", + fclt_mng_no: "", + inst_mng_no: "", + } + : null, + ); + window.setTimeout(() => { + setGisEditDetailChangeId(selectedChangeId); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + } + }); + + return () => { + unByKey(clickKey); + }; + }, [ + applySelectedVertexHandleStyle, + clearSelectedVertexHandle, + gisEdit.activeTool, + gisEdit.changes, + gisEdit.isEditMode, + gisEdit.pendingInteraction, + gisEdit.announceStatus, + gisEdit.registerFeatureSelection, + gisEdit.selectedLayer, + mapReady, + openGisEditPipeDetail, + ]); + + const gisEditSelectedLayerId = gisEdit.selectedLayer?.layerId ?? ""; + const gisEditSelectedLayerGeometryType = gisEdit.selectedLayer?.geometryType ?? null; + const gisEditSelectedLayerPreviewColors = gisEdit.selectedLayer?.previewColors ?? null; + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; + + const map = gisMaps.current; + + if (gisEdit.activeTool !== "ADD" || !gisEditSelectedLayerId || !gisEditSelectedLayerGeometryType) { + removeGisEditDrawInteraction(); + return; + } + + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + + const drawSource = ensureGisEditPreviewLayer(); + if (!drawSource) return; + const drawType = + gisEditSelectedLayerGeometryType === "Point" + ? "Point" + : gisEditSelectedLayerGeometryType === "Polygon" + ? "Polygon" + : "LineString"; + + const draw = new Draw({ + source: drawSource, + type: drawType, + }); + const pipeSnapSource = shouldUsePipeSnapSource ? ensureGisEditPipeSnapSource() : null; + + draw.on("drawstart", () => { + setGisEditPreviewCursor("crosshair"); + }); + + draw.on("drawend", (event: any) => { + try { + const pipeSnapSource = shouldUsePipeSnapSource ? ensureGisEditPipeSnapSource() : null; + if (pipeSnapSource?.getFeatures?.().length) { + applyGeometryVertexSnaps(event.feature.getGeometry(), pipeSnapSource, map, 20); + } + const geometryObject = new GeoJSON().writeGeometryObject(event.feature.getGeometry(), { + featureProjection: GisApp.TO_PROJ, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + const changeId = gisEdit.registerAddGeometry(geometryObject); + if (changeId) { + event.feature.setId(`gis-edit:${changeId}`); + event.feature.set("fid", changeId); + event.feature.set("editType", "ADD"); + event.feature.set("objectId", changeId); + event.feature.set("layerId", gisEditSelectedLayerId); + event.feature.set("changeId", changeId); + event.feature.set("layerPointColor", gisEditSelectedLayerPreviewColors?.pointColor ?? ""); + event.feature.set("layerLineColor", gisEditSelectedLayerPreviewColors?.lineColor ?? ""); + event.feature.set("layerFillColor", gisEditSelectedLayerPreviewColors?.fillColor ?? ""); + event.feature.set("isInteractionTarget", false); + event.feature.set("isDragging", false); + event.feature.set("previewLabel", "신규 추가"); + event.feature.setStyle(createGisEditPreviewStyle(event.feature)); + gisMaps.current?.renderSync?.(); + window.setTimeout(() => { + const source = ensureGisEditPreviewLayer(); + if (!source) return; + if (!source.getFeatureById?.(`gis-edit:${changeId}`)) { + source.addFeature(event.feature); + } + event.feature.changed?.(); + source.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, 0); + } else { + window.setTimeout(() => { + drawSource.removeFeature(event.feature); + }, 0); + } + if (changeId && gisEditSelectedLayerId === "tb_ms_swage_ppl") { + openGisEditPipeDetail(changeId); + setFeatureData({ + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: "", + mngInstCd: "", + fclt_mng_no: "", + inst_mng_no: "", + }); + window.setTimeout(() => { + setGisEditDetailChangeId(changeId); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + window.setTimeout(() => { + gisEdit.deactivateTool(); + removeGisEditDrawInteraction(); + }, 0); + gisEdit.announceStatus("하수관로 신규 객체를 지도에 추가했습니다. 우측 상세정보를 입력한 뒤 저장하세요."); + } + } catch (error) { + console.error("GIS 편집 ADD geometry 변환 실패:", error); + gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요."); + } finally { + setGisEditPreviewCursor("crosshair"); + } + }); + + map.addInteraction(draw); + gisEditDrawRef.current = draw; + if (pipeSnapSource) { + const drawSnap = new Snap({ + source: pipeSnapSource, + edge: true, + vertex: true, + pixelTolerance: 14, + }); + map.addInteraction(drawSnap); + gisEditDrawSnapRef.current = drawSnap; + } + + return () => { + removeGisEditDrawInteraction(); + }; + }, [ + ensureGisEditPreviewLayer, + ensureGisEditPipeSnapSource, + gisEdit.activeTool, + gisEdit.announceStatus, + gisEdit.deactivateTool, + gisEdit.isEditMode, + gisEdit.isSnapEnabled, + gisEdit.registerAddGeometry, + gisEditSelectedLayerGeometryType, + gisEditSelectedLayerId, + gisEditSelectedLayerPreviewColors, + mapReady, + openGisEditPipeDetail, + removeGisEditDrawInteraction, + removeGisEditInteractions, + setGisEditPreviewCursor, + shouldUsePipeSnapSource, + ]); + useEffect(() => { if (!mapReady || !flowDirectionLayerInfo) return; // 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다. if (isFlowDirectionChecked) { - loadFlowDirectionLayer(flowDirectionLayerInfo); + const requestUsageCodes = flowDirectionSelectionRequestUsageCodesRef.current; + flowDirectionSelectionRequestUsageCodesRef.current = null; + if (requestUsageCodes && requestUsageCodes.length === 0) return; + loadFlowDirectionLayer(flowDirectionLayerInfo, requestUsageCodes ?? undefined); } else { removeFlowDirectionLayer(); } @@ -1233,6 +6400,767 @@ const Map = () => { }; }, [loadFlowDirectionLayer, mapReady]); + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const source = ensureGisEditPreviewLayer(); + if (!source) return; + + if (gisEdit.changes.length === 0) { + clearGisEditPreviewLayer(); + return; + } + + const geoJson = new GeoJSON(); + const layerPreviewColorMap = new globalThis.Map( + gisEdit.editableLayers.map((layer) => [layer.layerId, layer.previewColors] as const), + ); + const existingFeatureMap = new globalThis.Map( + source.getFeatures().map((feature: any) => [String(feature.get("changeId") ?? ""), feature] as const), + ); + const nextChangeIdSet = new Set(); + + gisEdit.changes.forEach((change) => { + if (change.attributeOnly) return; + + const geometry = change.editType === "DELETE" ? change.beforeGeometry : change.afterGeometry; + if (!geometry) return; + + const previewEditType = + gisEdit.pendingInteraction?.changeId === change.changeId + ? gisEdit.pendingInteraction.editType + : change.editType; + const previewLabel = + gisEdit.focusedChangeId === change.changeId && previewEditType === "MOVE" + ? "드래그 이동" + : gisEdit.focusedChangeId === change.changeId && previewEditType === "VERTEX_EDIT" + ? "꼭지점 편집" + : previewEditType === "DELETE" + ? "삭제 예정" + : previewEditType === "MOVE" + ? "이동 예정" + : previewEditType === "VERTEX_EDIT" + ? "편집 예정" + : "신규 추가"; + + let feature = existingFeatureMap.get(change.changeId); + const previewColors = layerPreviewColorMap.get(change.layerId); + const originalProperties = change.featureProperties ?? {}; + const previewObjectId = change.objectId ?? change.objectLabel; + const previewFid = + originalProperties["fid"] ?? + change.fid ?? + change.featureRef?.fid ?? + previewObjectId; + if (!feature) { + feature = geoJson.readFeature( + { + type: "Feature", + geometry, + properties: { + ...originalProperties, + fid: previewFid, + editType: previewEditType, + objectId: previewObjectId, + layerId: change.layerId, + changeId: change.changeId, + }, + }, + { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }, + ); + feature.setId(`gis-edit:${change.changeId}`); + source.addFeature(feature); + } else { + feature.setGeometry( + geoJson.readGeometry(geometry, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }), + ); + } + + Object.entries(originalProperties).forEach(([key, value]) => { + if (key === "geometry") return; + feature.set(key, value); + }); + feature.set("fid", previewFid); + feature.set("editType", previewEditType); + feature.set("objectId", previewObjectId); + feature.set("layerId", change.layerId); + feature.set("changeId", change.changeId); + feature.set("layerPointColor", previewColors?.pointColor ?? ""); + feature.set("layerLineColor", previewColors?.lineColor ?? ""); + feature.set("layerFillColor", previewColors?.fillColor ?? ""); + feature.set("isInteractionTarget", gisEdit.focusedChangeId === change.changeId); + feature.set("isDragging", Boolean(feature.get("isDragging"))); + feature.set("previewLabel", previewLabel); + feature.setStyle(createGisEditPreviewStyle(feature)); + nextChangeIdSet.add(change.changeId); + }); + + existingFeatureMap.forEach((feature: any, changeId: string) => { + if (!nextChangeIdSet.has(changeId)) { + source.removeFeature(feature); + } + }); + source.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + + }, [ + clearGisEditPreviewLayer, + ensureGisEditPreviewLayer, + gisEdit.changes, + gisEdit.editableLayers, + gisEdit.focusedChangeId, + gisEdit.isEditMode, + gisEdit.pendingInteraction, + mapReady, + ]); + + useEffect(() => { + if (!mapReady || !gisEdit.isEditMode || gisEdit.changes.length === 0) return; + ensureGisEditPreviewLayer(); + gisEditPreviewSourceRef.current?.changed?.(); + gisEditPreviewLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, [ + ensureGisEditPreviewLayer, + gisEdit.changes.length, + gisEdit.isEditMode, + mapReady, + ]); + + const gisEditMaskedPipeFidKey = useMemo(() => { + const fids = gisEdit.changes + .filter((change) => change.layerId === "tb_ms_swage_ppl") + .filter((change) => !change.attributeOnly) + .filter((change) => change.editType !== "ADD" && change.editType !== "DELETE") + .map((change) => normalizeGisEditFid(change.fid ?? change.featureRef?.fid ?? change.objectId)) + .filter((fid) => /^\d+$/.test(fid)); + return Array.from(new Set(fids)).sort().join(","); + }, [gisEdit.changes]); + + useEffect(() => { + if (!mapReady || !gisEdit.isEditMode) { + restoreGisEditOriginalPipeMask(); + return; + } + + applyGisEditOriginalPipeMask(gisEditMaskedPipeFidKey ? gisEditMaskedPipeFidKey.split(",") : []); + }, [ + applyGisEditOriginalPipeMask, + gisEdit.isEditMode, + gisEditMaskedPipeFidKey, + mapReady, + restoreGisEditOriginalPipeMask, + ]); + + useEffect(() => () => { + restoreGisEditOriginalPipeMask(); + }, [restoreGisEditOriginalPipeMask]); + + useEffect(() => () => { + clearGisEditPipeSnapSource(); + }, [clearGisEditPipeSnapSource]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + if (!shouldUsePipeSnapSource) { + clearGisEditPipeSnapSource(); + return; + } + + const excludedFid = + gisEdit.pendingInteraction?.editType === "VERTEX_EDIT" || + gisEdit.pendingInteraction?.editType === "MOVE" + ? normalizeGisEditFid( + gisEdit.changes.find((change) => change.changeId === gisEdit.pendingInteraction?.changeId)?.fid ?? + "", + ) + : ""; + + void loadGisEditPipeSnapSource({ + excludedFid, + excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null, + }); + }, [ + clearGisEditPipeSnapSource, + gisEdit.changes, + gisEdit.pendingInteraction, + loadGisEditPipeSnapSource, + mapReady, + shouldUsePipeSnapSource, + ]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + if (!shouldUsePipeSnapSource) return; + + const key = gisMaps.current.on("moveend", () => { + const excludedFid = + gisEdit.pendingInteraction?.editType === "VERTEX_EDIT" || + gisEdit.pendingInteraction?.editType === "MOVE" + ? normalizeGisEditFid( + gisEdit.changes.find((change) => change.changeId === gisEdit.pendingInteraction?.changeId)?.fid ?? + "", + ) + : ""; + void loadGisEditPipeSnapSource({ + excludedFid, + excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null, + }); + }); + + return () => { + unByKey(key); + }; + }, [ + gisEdit.changes, + gisEdit.pendingInteraction, + loadGisEditPipeSnapSource, + mapReady, + shouldUsePipeSnapSource, + ]); + + useEffect(() => { + if (!mapReady || !gisMaps.current || !gisEdit.isEditMode || !gisEdit.pendingInteraction) return; + + const source = ensureGisEditPreviewLayer(); + const map = gisMaps.current; + if (!source || !map) return; + + const targetFeature = source + .getFeatures() + .find((feature: any) => feature.get("changeId") === gisEdit.pendingInteraction?.changeId); + if (!targetFeature) { + gisEdit.clearPendingInteraction(); + return; + } + + removeGisEditInteractions(); + const features = new Collection([targetFeature]); + let cleanupPointerMove: (() => void) | undefined; + const writeGeometryObject = () => { + const geometry = targetFeature.getGeometry(); + if (!geometry) return null; + return new GeoJSON().writeGeometryObject(geometry, { + featureProjection: GisApp.TO_PROJ, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record; + }; + const usePipeSnapSource = + shouldUsePipeSnapSource && + (gisEdit.pendingInteraction.editType === "MOVE" || gisEdit.pendingInteraction.editType === "VERTEX_EDIT"); + const pipeSnapSource = usePipeSnapSource ? ensureGisEditPipeSnapSource() : null; + const pipeSnapSourceHasFeatures = Boolean(pipeSnapSource?.getFeatures?.().length); + const snapFeatures = + (!usePipeSnapSource || !pipeSnapSourceHasFeatures) && gisEdit.isSnapEnabled + ? collectGisEditSnapFeatures(targetFeature) + : []; + + if (gisEdit.pendingInteraction.editType === "MOVE") { + const translate = new Translate({ features, hitTolerance: 10 }); + let lastTranslateCoordinate: number[] | null = null; + let translateStartCoordinate: number[] | null = null; + let originalMoveGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + let moveGripCoordinate: number[] | null = null; + const moveGripMeta = + gisEditMoveGripMetaRef.current?.changeId === gisEdit.pendingInteraction.changeId + ? gisEditMoveGripMetaRef.current.meta + : null; + translate.on("translatestart", (event: any) => { + lastTranslateCoordinate = Array.isArray(event?.coordinate) ? event.coordinate : null; + translateStartCoordinate = Array.isArray(event?.coordinate) ? event.coordinate : null; + originalMoveGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + moveGripCoordinate = + getGeometryVertexCoordinateByMeta(originalMoveGeometrySnapshot, moveGripMeta) ?? + getClosestGeometryVertexCoordinate(originalMoveGeometrySnapshot, map, translateStartCoordinate); + targetFeature.set("isDragging", true); + targetFeature.set("previewLabel", "이동 중"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grabbing"); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + translate.on("translating", (event: any) => { + lastTranslateCoordinate = Array.isArray(event?.coordinate) ? event.coordinate : lastTranslateCoordinate; + if (originalMoveGeometrySnapshot?.clone && Array.isArray(lastTranslateCoordinate) && Array.isArray(translateStartCoordinate)) { + const geometry = originalMoveGeometrySnapshot.clone(); + const deltaX = Number(lastTranslateCoordinate[0] ?? 0) - Number(translateStartCoordinate[0] ?? 0); + const deltaY = Number(lastTranslateCoordinate[1] ?? 0) - Number(translateStartCoordinate[1] ?? 0); + geometry.translate(deltaX, deltaY); + if (pipeSnapSource && pipeSnapSourceHasFeatures) { + const movedGripCoordinate = + Array.isArray(moveGripCoordinate) + ? [ + Number(moveGripCoordinate[0] ?? 0) + deltaX, + Number(moveGripCoordinate[1] ?? 0) + deltaY, + ] + : lastTranslateCoordinate; + applyGeometryMoveSnap(geometry, pipeSnapSource, map, movedGripCoordinate, 28); + } + targetFeature.setGeometry(geometry); + } + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + translate.on("translateend", (event: any) => { + const objectId = String(targetFeature.get("objectId") ?? ""); + const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어"; + lastTranslateCoordinate = Array.isArray(event?.coordinate) ? event.coordinate : lastTranslateCoordinate; + targetFeature.set("isDragging", false); + targetFeature.set("previewLabel", "드래그 이동"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grab"); + if (originalMoveGeometrySnapshot?.clone && Array.isArray(lastTranslateCoordinate) && Array.isArray(translateStartCoordinate)) { + const geometry = originalMoveGeometrySnapshot.clone(); + const deltaX = Number(lastTranslateCoordinate[0] ?? 0) - Number(translateStartCoordinate[0] ?? 0); + const deltaY = Number(lastTranslateCoordinate[1] ?? 0) - Number(translateStartCoordinate[1] ?? 0); + geometry.translate(deltaX, deltaY); + if (pipeSnapSource && pipeSnapSourceHasFeatures) { + const movedGripCoordinate = + Array.isArray(moveGripCoordinate) + ? [ + Number(moveGripCoordinate[0] ?? 0) + deltaX, + Number(moveGripCoordinate[1] ?? 0) + deltaY, + ] + : lastTranslateCoordinate; + applyGeometryMoveSnap(geometry, pipeSnapSource, map, movedGripCoordinate, 28); + } + targetFeature.setGeometry(geometry); + } + gisEdit.updateChangeGeometry( + gisEdit.pendingInteraction!.changeId, + writeGeometryObject(), + `${layerName} ${objectId} 객체 위치를 지도에서 이동했습니다.`, + { keepMoveToolActive: true }, + ); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + map.addInteraction(translate); + gisEditTranslateRef.current = translate; + if (pipeSnapSource && pipeSnapSourceHasFeatures) { + const snap = new Snap({ + source: pipeSnapSource, + edge: true, + vertex: true, + pixelTolerance: 14, + }); + map.addInteraction(snap); + gisEditSnapRef.current = snap; + } else if (gisEdit.isSnapEnabled && snapFeatures.length > 0) { + const snap = new Snap({ + features: new Collection(snapFeatures), + edge: true, + vertex: true, + pixelTolerance: 14, + }); + map.addInteraction(snap); + gisEditSnapRef.current = snap; + } + setGisEditPreviewCursor("grab"); + map.renderSync?.(); + } + + if (gisEdit.pendingInteraction.editType === "VERTEX_EDIT") { + const geometry = targetFeature.getGeometry?.(); + const geometryType = geometry?.getType?.(); + const objectId = String(targetFeature.get("objectId") ?? ""); + const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어"; + + if (!geometry || !geometryType || geometryType === "Point" || geometryType === "MultiPoint") { + gisEdit.announceStatus(`${layerName} ${objectId} 객체는 꼭지점편집 대상 geometry가 아닙니다.`); + gisEdit.clearPendingInteraction(); + return; + } + + const vertexSource = ensureGisEditVertexLayer(); + const vertexLayer = gisEditVertexLayerRef.current; + if (!vertexSource || !vertexLayer) { + gisEdit.announceStatus("꼭지점 편집 레이어를 준비하지 못했습니다."); + gisEdit.clearPendingInteraction(); + return; + } + + const renderVertexHandles = (hoveredMidpoint: VertexHandleCandidate | null = null) => { + vertexSource.clear(); + buildVertexHandleFeatures(targetFeature.getGeometry?.(), map).forEach((handleFeature: Feature) => { + vertexSource.addFeature(handleFeature); + }); + if (hoveredMidpoint) { + vertexSource.addFeature( + createVertexHandleFeature(hoveredMidpoint.coordinate, hoveredMidpoint.meta), + ); + } + vertexSource.changed?.(); + applySelectedVertexHandleStyle(); + }; + + let geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject()); + let activeMidpointKey = ""; + let isDraggingHandle = false; + let originalGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + renderVertexHandles(); + targetFeature.set("previewLabel", "꼭지점 편집"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("crosshair"); + + const pointerMoveKey = map.on("pointermove", (event: any) => { + if (isDraggingHandle || event?.dragging) return; + + const hoveredMidpoint = findHoveredMidpointCandidate( + map, + targetFeature.getGeometry?.(), + event.coordinate, + ); + const nextMidpointKey = hoveredMidpoint + ? `${hoveredMidpoint.meta.geometryType}:${hoveredMidpoint.meta.partIndex ?? -1}:${hoveredMidpoint.meta.polygonIndex ?? -1}:${hoveredMidpoint.meta.ringIndex ?? -1}:${hoveredMidpoint.meta.coordIndex}` + : ""; + + if (nextMidpointKey === activeMidpointKey) return; + + activeMidpointKey = nextMidpointKey; + renderVertexHandles(hoveredMidpoint); + setGisEditPreviewCursor(hoveredMidpoint ? "copy" : "crosshair"); + map.renderSync?.(); + }); + + const vertexTranslate = new Translate({ + layers: [vertexLayer], + hitTolerance: 14, + }); + + vertexTranslate.on("translatestart", (event: any) => { + isDraggingHandle = true; + geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject()); + originalGeometrySnapshot = targetFeature.getGeometry?.()?.clone?.() ?? null; + clearSelectedVertexHandle(); + const handleFeature = event.features?.item?.(0); + if (handleFeature) { + handleFeature.setStyle(GIS_EDIT_VERTEX_DRAGGING_STYLE); + } + targetFeature.set("previewLabel", "꼭지점 편집 중"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("grabbing"); + map.renderSync?.(); + }); + + vertexTranslate.on("translating", (event: any) => { + const handleFeature = event.features?.item?.(0); + const handleGeometry = handleFeature?.getGeometry?.(); + const nextCoordinate = handleGeometry?.getCoordinates?.(); + const handleMeta = handleFeature?.get?.("handleMeta") as VertexHandleMeta | undefined; + const handleMetaList = + (handleFeature?.get?.("handleMetaList") as VertexHandleMeta[] | undefined) ?? + (handleMeta ? [handleMeta] : []); + + if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate) || !originalGeometrySnapshot?.clone) { + return; + } + + targetFeature.setGeometry(originalGeometrySnapshot.clone()); + handleMetaList.forEach((meta) => { + applyVertexHandleMove(targetFeature.getGeometry?.(), meta, nextCoordinate); + }); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + + vertexTranslate.on("translateend", (event: any) => { + isDraggingHandle = false; + const handleFeature = event.features?.item?.(0); + const handleGeometry = handleFeature?.getGeometry?.(); + const nextCoordinate = handleGeometry?.getCoordinates?.(); + const handleMeta = handleFeature?.get?.("handleMeta") as VertexHandleMeta | undefined; + const handleMetaList = + (handleFeature?.get?.("handleMetaList") as VertexHandleMeta[] | undefined) ?? + (handleMeta ? [handleMeta] : []); + + targetFeature.set("previewLabel", "꼭지점 편집"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + setGisEditPreviewCursor("crosshair"); + activeMidpointKey = ""; + + if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate)) { + renderVertexHandles(); + map.renderSync?.(); + return; + } + + handleMetaList.forEach((meta) => { + applyVertexHandleMove(targetFeature.getGeometry?.(), meta, nextCoordinate); + }); + const nextGeometry = writeGeometryObject(); + const geometryChanged = geometryBeforeModifyKey !== geometryObjectToKey(nextGeometry); + renderVertexHandles(); + if (!geometryChanged) { + gisEdit.announceStatus( + `${layerName} ${objectId} 객체는 아직 형상이 바뀌지 않았습니다. 주황색 꼭지점 또는 가상 꼭지점을 직접 드래그해 주세요.`, + ); + map.renderSync?.(); + return; + } + + gisEdit.updateChangeGeometry( + gisEdit.pendingInteraction!.changeId, + nextGeometry, + `${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`, + { keepVertexEditActive: true }, + ); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }); + + map.addInteraction(vertexTranslate); + gisEditModifyRef.current = vertexTranslate; + if (pipeSnapSource && pipeSnapSourceHasFeatures) { + const snap = new Snap({ + source: pipeSnapSource, + edge: true, + vertex: true, + pixelTolerance: 14, + }); + map.addInteraction(snap); + gisEditSnapRef.current = snap; + } else if (gisEdit.isSnapEnabled && snapFeatures.length > 0) { + const snap = new Snap({ + features: new Collection(snapFeatures), + edge: true, + vertex: true, + pixelTolerance: 14, + }); + map.addInteraction(snap); + gisEditSnapRef.current = snap; + } + gisMaps.current?.renderSync?.(); + cleanupPointerMove = () => { + unByKey(pointerMoveKey); + }; + } + + return cleanupPointerMove; + }, [ + applySelectedVertexHandleStyle, + clearGisEditVertexLayer, + clearSelectedVertexHandle, + collectGisEditSnapFeatures, + ensureGisEditPipeSnapSource, + ensureGisEditVertexLayer, + ensureGisEditPreviewLayer, + gisEdit.isSnapEnabled, + gisEdit.pendingInteraction, + gisEdit.selectedLayer, + gisEdit.updateChangeGeometry, + gisEdit.announceStatus, + gisEdit.clearPendingInteraction, + gisEdit.isEditMode, + mapReady, + removeGisEditInteractions, + shouldUsePipeSnapSource, + ]); + + useEffect(() => { + if ( + !mapReady || + !gisMaps.current || + !gisEdit.isEditMode || + gisEdit.pendingInteraction?.editType !== "VERTEX_EDIT" + ) { + clearSelectedVertexHandle(); + return; + } + + const pendingInteraction = gisEdit.pendingInteraction; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Delete") return; + if (isTypingElement(event.target) || isTypingElement(document.activeElement)) return; + + const source = ensureGisEditPreviewLayer(); + const map = gisMaps.current; + if (!source || !map) return; + + const targetFeature = source + .getFeatures() + .find((feature: any) => feature.get("changeId") === gisEdit.pendingInteraction?.changeId); + if (!targetFeature) return; + + const selectedMetaList = gisEditSelectedVertexMetaListRef.current ?? []; + if (selectedMetaList.length === 0) { + gisEdit.announceStatus("Delete로 삭제할 주황색 실꼭지점을 먼저 클릭하세요."); + return; + } + + const deleteResult = deleteSelectedVertexHandles(targetFeature.getGeometry?.(), selectedMetaList); + if (!deleteResult.ok) { + gisEdit.announceStatus(deleteResult.message ?? "꼭지점을 삭제하지 못했습니다."); + return; + } + + event.preventDefault(); + clearSelectedVertexHandle(); + + const objectId = String(targetFeature.get("objectId") ?? ""); + const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어"; + const geometry = targetFeature.getGeometry(); + if (!geometry) { + gisEdit.announceStatus("꼭지점 삭제 후 geometry를 확인하지 못했습니다."); + return; + } + targetFeature.set("previewLabel", "꼭지점 편집"); + targetFeature.setStyle(createGisEditPreviewStyle(targetFeature)); + gisEdit.updateChangeGeometry( + pendingInteraction.changeId, + new GeoJSON().writeGeometryObject(geometry, { + featureProjection: GisApp.TO_PROJ, + dataProjection: FLOW_DIRECTION_SRS, + }) as Record, + `${layerName} ${objectId} 객체 꼭지점을 삭제했습니다.`, + { keepVertexEditActive: true }, + ); + targetFeature.changed?.(); + source.changed?.(); + map.renderSync?.(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + clearSelectedVertexHandle, + ensureGisEditPreviewLayer, + gisEdit.isEditMode, + gisEdit.pendingInteraction, + gisEdit.selectedLayer, + gisEdit.updateChangeGeometry, + gisEdit.announceStatus, + mapReady, + ]); + + useEffect(() => { + if (!gisEdit.isEditMode || (gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT")) { + removeGisEditInteractions(); + } + }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditInteractions]); + + useEffect(() => { + const select = GisApp.Module.select as any; + if (!select?.setActive) return; + + if (gisEdit.isEditMode && gisEdit.activeTool) { + select.resetSelectedFeature?.(); + select.setActive(false); + return; + } + + select.setActive(true); + }, [gisEdit.activeTool, gisEdit.isEditMode, mapReady]); + + useEffect(() => { + if (!gisEdit.isEditMode || gisEdit.activeTool !== "ADD") { + removeGisEditDrawInteraction(); + } + }, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]); + + useEffect(() => { + if (!mapReady || !isSinkholeChecked) { + removeSinkholeLayer(); + return; + } + + void loadSinkholeLayer(appliedSinkholeFilters); + }, [ + appliedSinkholeFilters, + isSinkholeChecked, + loadSinkholeLayer, + mapReady, + removeSinkholeLayer, + sinkholeSelectionVersion, + ]); + + useEffect(() => { + const popup = sinkholePopupWindowRef.current; + if (!popup || popup.closed) { + sinkholePopupWindowRef.current = null; + return; + } + + const effectiveFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); + popup.postMessage( + { + type: SINKHOLE_FILTER_SYNC_MESSAGE, + filters: effectiveFilters, + }, + window.location.origin, + ); + }, [appliedSinkholeFilters, sinkholeScope, sinkholeSelectionVersion]); + + useEffect(() => { + renderSelectedSinkhole(selectedSinkholeDetail, sinkholeRadiusMeters); + }, [renderSelectedSinkhole, selectedSinkholeDetail, sinkholeRadiusMeters]); + + useEffect(() => { + renderSelectedFloodHistory(selectedFloodHistoryDetail); + }, [renderSelectedFloodHistory, selectedFloodHistoryDetail]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + const map = gisMaps.current; + const clickKey = map.on("singleclick", (event: any) => { + if (!selectedFloodHistoryDetail || !isFloodHistoryChecked) return; + + let clickedFloodHistory = false; + map.forEachFeatureAtPixel( + event.pixel, + (feature: any, layer: any) => { + if ( + layer === floodHistoryAreaLayerRef.current || + layer === floodHistoryDepthLayerRef.current + ) { + clickedFloodHistory = true; + return feature; + } + return undefined; + }, + { hitTolerance: 4 }, + ); + + if (!clickedFloodHistory) return; + + const size = map.getSize?.() ?? [1200, 800]; + const popupWidth = 220; + const popupHeight = 210; + const nextLeft = Math.min(Math.max(event.pixel[0] + 12, 12), size[0] - popupWidth - 12); + const nextTop = Math.min(Math.max(event.pixel[1] - 12, 12), size[1] - popupHeight - 12); + setFloodHistoryDetailPopupPosition({ + left: nextLeft, + top: nextTop, + }); + setIsSearchModalOpen(false); + }); + + return () => { + unByKey(clickKey); + }; + }, [isFloodHistoryChecked, mapReady, selectedFloodHistoryDetail, setIsSearchModalOpen]); + + useEffect(() => { + return () => { + clearFloodHistoryLayers(); + }; + }, [clearFloodHistoryLayers]); + return ( <> @@ -1247,9 +7175,163 @@ const Map = () => { {agedPipeDownloadProgressText}
) : null} + {waterRiskSession.isSettingEditMode ? ( +
+
+ 통수위험 설정 모드: {waterRiskSession.selectedSettingAreaName || "중점관리지역 선택 전"} +
+
+ 지도에서 하수관로를 클릭하면 staged 목록에 추가됩니다. 최대 3건까지 선택할 수 있습니다. +
+
+ ) : null} + {selectedWaterRiskResultRow && waterRiskInfoCardStyle ? ( +
+
+
+
중점관리지역
+
+ {selectedWaterRiskResultRow.areaName} +
+
+ + {buildWaterRiskStatusLabel(selectedWaterRiskResultRow.riskStatus)} + +
+ +
+
+ + + + + + + + + + + + + {selectedWaterRiskResultRow.pipeSummaries.map((pipe, index) => { + const isDanger = pipe.runoffFlowQty > pipe.conveyQty; + const isLast = index === selectedWaterRiskResultRow.pipeSummaries.length - 1; + + return ( + + + + + + + + + + + + ); + })} + +
+ 예측강우량(mm/hr) + + {selectedWaterRiskResultRow.forecastRainQty.toFixed(1)} +
+
+
+
+ 관로 +
+ ID {index + 1} +
+
+ {pipe.pipeId} +
+
+ + {isDanger ? "위험" : "정상"} + +
+
+ 통수량 +
+ (㎥/hr) +
+ {pipe.conveyQty.toFixed(1)} +
+ 유출유량 +
+ (㎥/hr) +
+ {pipe.runoffFlowQty.toFixed(1)} +
+
+
+
+ ) : null} + {selectedFloodHistoryDetail && floodHistoryDetailPopupPosition ? ( + + ) : null} setIsSearchModalOpen(true)} + onToggleGisEdit={handleToggleGisEdit} + isGisEditActive={gisEdit.isEditMode} + isInfoSearchActive={isSearchModalOpen} + /> + {isGisEditSessionInlineVisible ? ( +
+ +
+ ) : null} + { gisMaps={gisMaps} isMiniMap={false} searchView={SearchModal} - detailView={FeatureClickEventPopup} + detailView={GisEditAwareDetailView} + canSelectFeature={canSelectFeatureDuringGisEdit} onFeatureSelect={onFeatureSelect} + onMapBlankClick={handleSinkholeMapBlankClick} + sinkholeLegendIconVisible={true} featureData={featureData} setFeatureData={setFeatureData} isOpen={isOpen} @@ -1272,11 +7357,13 @@ const Map = () => { gisMaps={gisMaps} facilityTypes={facilityTypes} sidoSearchZoom={isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined} + facilityLayerFilter={swagePrcsFcltSearchFilter} /> } - waterDepth={showWaterDepth ? WaterDepth : undefined} - inlineLayerSettingView={inlineLayerSettingView} - layerIconVisible={isAgedPipeLayerNode} + waterDepth={undefined} + layerSettingView={ActiveLayerSettingView} + treeSectionClassName="rounded-sm border border-border bg-background px-1 py-2 text-foreground" + detachedTreeToggleButton={true} isPlaying={isPlaying} setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} @@ -1285,18 +7372,14 @@ const Map = () => { showZoomLevel={true} showZoomScaleInfo={true} showFloatingSearchButton={false} - treeTogglePosition="top" - zoomGuide={ - isAgedPipeChecked - ? { - label: "노후관로 기준", - zoom: AGED_PIPE_GUIDE_MIN_ZOOM, - } - : undefined + treeTogglePosition="center" + zoomGuide={zoomGuide} + layerIconVisible={(node: any) => + isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFloodHistoryNode(node) } defaultZoom={7.5} - defaultLonLat={[127.75, 36]} + defaultLonLat={[129, 36.3]} viewExtent={SOUTH_KOREA_VIEW_EXTENT} /> {isAgedPipeListMinimized && ( diff --git a/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx index c21dc8ca4..631ba12fb 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx @@ -34,14 +34,14 @@ const BasicTab: React.FC = () => { const { basicType, setBasicType } = useSearchModalStore(); return ( -
+
{/* 구분 */}
- + @@ -63,4 +63,4 @@ const BasicTab: React.FC = () => { ); }; -export default React.memo(BasicTab); \ No newline at end of file +export default React.memo(BasicTab); diff --git a/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx index 63c18875d..8ef37730e 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx @@ -37,12 +37,12 @@ const OperationTab: React.FC = () => { })); return ( -
+
- + ({ val: w.val, @@ -54,7 +54,7 @@ const OperationTab: React.FC = () => { />
- + { startLabel="수행기간" endLabel="" /> - + { ) }; -export default React.memo(OperationTab); \ No newline at end of file +export default React.memo(OperationTab); diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index f054a9ed1..8ce577d8f 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -1,18 +1,19 @@ -/** +/** * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 * * 📄 Report Tab * Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트 * * - 하수도정비 기본계획 보고서 목록 표시 및 필터링 - * - 시도/시군구/보고서 구분/승인년도 기반 검색 기능 + * - 시도/시군구/구분/보고서 구분/승인년도 기반 검색 기능 * - 테이블 데이터 표시 및 페이지네이션 지원 - * - 보고서 클릭 시 파일 목록 모달 열기 + * - 파일명 클릭 시 대표 파일 바로 다운로드 + * - 탭 진입 시 기본 조건 자동 조회 지원 * - Zustand를 통한 모달 상태 및 데이터 관리 * - 로딩 오버레이 및 선택 초기화 기능 */ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Label } from '@common/components'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; import { SelectBox, UiTable } from '@common/business_components/ui'; @@ -22,19 +23,17 @@ import useStore from '@/store/useStore'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import useSearchModalStore from '@/store/useSearchModalStore'; import { useReportTabStore } from '@/store/useReportTabStore'; -import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; - // 하수도정비 기본계획 보고서 export const reportTableHeadlist = [ - { id: "no", title: "NO" }, - { id: "sido", title: "시도" }, - { id: "sigungu", title: "시군구" }, - { id: "approvalYear", title: "승인년도" }, - { id: "plantZoneGubun", title: "구분" }, - { id: "reportGubun", title: "보고서구분" }, - { id: "fileName", title: "파일명" }, - { id: "regDate", title: "등록일자" }, + { id: 'no', title: 'NO' }, + { id: 'sido', title: '시도' }, + { id: 'sigungu', title: '시군구' }, + { id: 'approvalYear', title: '승인년도' }, + { id: 'plantZoneGubun', title: '구분' }, + { id: 'reportGubun', title: '보고서구분' }, + { id: 'fileName', title: '파일명' }, + { id: 'regDate', title: '등록일자' }, ]; interface ReportTabProps { open?: boolean; @@ -43,19 +42,26 @@ interface ReportTabProps { sggNm?: string; } -const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sggNm }) => { +const normalizeYear = (value: string) => { + if (!value || value === '0' || value === '9999') return ''; + return value; +}; - // const [isLoading, setIsLoading] = useState(false); +const getApprovalYearNumber = (value: string | number | undefined) => { + const normalized = String(value ?? '').replace('년', '').trim(); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +}; + +const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sggNm }) => { const [isResetPage, setIsResetPage] = useState(false); const [isResetSelection, setIsResetSelection] = useState(false); const [showTable, setShowTable] = useState(false); - // const [tableData, setTableData] = useState([]); - // const [tableCount, setTableCount] = useState(0); - // const [curPage, setCurPage] = useState(1); - // const [yearFilter, setYearFilter] = useState({ start: "", end: "" }); const [ctpvCd, setCtpvCd] = useState('0'); const [sggCd, setSggCd] = useState('0'); - const [reportGubun, setReportGubun] = useState("0"); + const [plantZoneGubun, setPlantZoneGubun] = useState('0'); + const [reportGubun, setReportGubun] = useState('0'); + const hasAutoFetchedRef = useRef(false); const { tableData, @@ -73,26 +79,43 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg const { dcTree: reportOriginData, fetchDcTree - } = useReportTabStore(); // 모달 상태 관리 스토어 - const { REPORT_TYPES, flattenFolders, addFileName } = useReportTab(); + } = useReportTabStore(); + const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab(); + + const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]); + const plantZoneOptions = useMemo(() => { + const uniqueOptions = Array.from( + new Set( + flattenedReportData + .map((item) => String(item.plantZoneGubun ?? '').trim()) + .filter((item) => item.length > 0) + ) + ).sort((a, b) => a.localeCompare(b, 'ko')); + + return [ + { val: '0', text: '전체' }, + ...uniqueOptions.map((item) => ({ val: item, text: item })), + ]; + }, [flattenedReportData]); useEffect(() => { - fetchDcTree("기본계획"); // 하수정비기본계획 report - }, []); + void fetchDcTree('기본계획'); + }, [fetchDcTree]); useEffect(() => { - // filterParams 초기화 + hasAutoFetchedRef.current = false; setCtpvCd('0'); setSggCd('0'); + setPlantZoneGubun('0'); setReportGubun('0'); - setYearFilter({ start: "", end: "" }); + setYearFilter({ start: '', end: '' }); setShowTable(false); setTableData([]); setTableCount(0); - setCurPage(1); // 페이지를 1로 초기화 - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(true); // 선택 초기화 - }, [open]); + setCurPage(1); + setIsResetPage(true); + setIsResetSelection(true); + }, [open, setCurPage, setTableCount, setTableData, setYearFilter]); useEffect(() => { const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0'; @@ -100,174 +123,189 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg setCtpvCd(newCtpvCd); setSggCd(newSggCd); - }, [ctpvNm, sggNm]); + }, [ctpv, ctpvNm, sgg, sggNm]); - // ctpvCd, sggCd가 업데이트된 후에 fetchFilteredData 실행 useEffect(() => { - if (!isSearch && ctpvCd && sggCd) { - fetchFilteredData(); - } - }, [reportOriginData, ctpvCd, sggCd, isSearch]); + if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current) return; + + hasAutoFetchedRef.current = true; + void fetchFilteredData(1); + }, [flattenedReportData.length, open]); - // 페이지 변경 시마다 데이터 fetch useEffect(() => { - // showTable이 true일 때만 fetch (최초 렌더 방지) setIsResetPage(false); - if (showTable) fetchFilteredData(); + if (showTable) { + void fetchFilteredData(); + } }, [curPage]); const handleCtpvCdChange = (sidoId: string) => { setCtpvCd(sidoId); setSggCd('0'); - } + }; - // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 const handleSearch = () => { - setCurPage(1); // 페이지를 1로 초기화 - fetchFilteredData(); - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(false); // 선택 초기화 + hasAutoFetchedRef.current = true; + setCurPage(1); + void fetchFilteredData(1); + setIsResetPage(true); + setIsResetSelection(false); setShowTable(true); }; - // 하수도정비기본계획 테이블 cellClick 핸들러 - const handleReportCellClick = (index: number | undefined) => { - if (typeof index === "number" && tableData[index]) { - const reportData = tableData[index]; - if (reportData.fileSn !== "") { - const popupWindow = openWindowPopup("reportFiles", { - clsfSystmSn: reportData.clsfSystmSn, - sourcePath: window.location.pathname, - }); + const handleReportCellClick = (index: number | undefined, headId: string) => { + if (headId !== 'fileName') return; + if (typeof index !== 'number' || !tableData[index]) return; - if (!popupWindow) { - alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); - } + const reportData = tableData[index]; + if (!reportData.fileSn) return; + + void (async () => { + setIsLoading(true); + try { + await downloadReportFile(reportData); + } finally { + setIsLoading(false); } - } + })(); }; - // 검색 API 호출 함수 - const fetchFilteredData = async () => { - setIsLoading(true); // 로딩 시작 - setIsResetSelection(true); // 선택 초기화 + const fetchFilteredData = async (page = curPage) => { + setIsLoading(true); + setIsResetSelection(true); try { - const reportData = flattenFolders(reportOriginData); - let filterReportData = reportData; + let filterReportData = flattenedReportData; - if (ctpvCd !== '0') { // 시도검색 - const sidoOption = ctpv.find((n) => n.val.trim() === ctpvCd.trim()); - filterReportData = filterReportData.filter(item => item.sido === sidoOption?.text); + if (ctpvCd !== '0') { + const sidoOption = ctpv.find((n) => String(n.val).trim() === ctpvCd.trim()); + filterReportData = filterReportData.filter((item) => item.sido === sidoOption?.text); } - if (sggCd !== '0') { // 시군구검색 + if (sggCd !== '0') { const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd)); - filterReportData = filterReportData.filter(item => item.sigungu === sigunguOption?.text); + filterReportData = filterReportData.filter((item) => item.sigungu === sigunguOption?.text); } - if (reportGubun !== "0") { // 보고서구분검색 + if (plantZoneGubun !== '0') { + filterReportData = filterReportData.filter((item) => String(item.plantZoneGubun ?? '') === plantZoneGubun); + } + if (reportGubun !== '0') { const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun); - filterReportData = filterReportData.filter(item => item.reportGubun === reportGubunOption?.text); + filterReportData = filterReportData.filter((item) => item.reportGubun === reportGubunOption?.text); } - const { start, end } = yearFilter; - if (start && !end) //승인년도검색 - filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" : null); - else if (!start && end) - filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear <= yearFilter.end + "년" : null); - else if (start && end) { - filterReportData = filterReportData.filter(item => { - return (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" && item.approvalYear <= yearFilter.end + "년" : null; + const normalizedStartYear = normalizeYear(yearFilter.start); + const normalizedEndYear = normalizeYear(yearFilter.end); + if (normalizedStartYear || normalizedEndYear) { + filterReportData = filterReportData.filter((item) => { + const approvalYear = getApprovalYearNumber(item.approvalYear); + if (approvalYear === null) return false; + if (normalizedStartYear && approvalYear < Number(normalizedStartYear)) return false; + if (normalizedEndYear && approvalYear > Number(normalizedEndYear)) return false; + return true; }); } - // if (gubun !== "") { // 구분검색 (추후 사용할 수도 있을 것 같아 남겨둠) - // filterReportData = filterReportData.filter(item => (item.plantZoneGubun) ? String(item.plantZoneGubun).includes(String(gubun)) : null); - // } setTableCount(filterReportData.length || 0); - filterReportData = filterReportData.slice((curPage - 1) * 10, curPage * 10); // paging - filterReportData = filterReportData?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); + const pagedReportData = filterReportData + .slice((page - 1) * 10, page * 10) + .map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 })); - addFileName(filterReportData).then(data => { - filterReportData = data; - setTableData(filterReportData || []); - setIsResetSelection(false); // 데이터 설정 후 선택 초기화 해제 - }); - - setTableData(filterReportData || []); + const reportRows = await addFileName(pagedReportData); + setTableData(reportRows || []); setShowTable(true); + setIsResetSelection(false); } catch (err) { console.error('API ERROR:', err); setTableData([]); - setIsResetSelection(false); // 에러 시에도 선택 초기화 해제 + setIsResetSelection(false); } finally { - setIsLoading(false); // 로딩 종료 + setIsLoading(false); } }; - return ( <> -
- {isSearch && ( - <> -
+
+
+
+ {isSearch && ( { handleCtpvCdChange(sidoId) }} + onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId); }} onSigunguChange={setSggCd} setIsLoading={setIsLoading} /> + )} + setYearFilter({ + start: normalizeYear(range.start), + end: normalizeYear(range.end), + })} + startLabel="승인년도" + endLabel="" + /> + + setPlantZoneGubun(String(val))} + selectContentClass="z-[101]" + /> + + setReportGubun(String(val))} + selectContentClass="z-[101]" + /> +
+
- - )} -
- - setReportGubun(String(val))} - selectContentClass="z-[101]" labelClass='whitespace-nowrap text-xl text-gray-600 pr-2' - /> - {/* 승인년도 필터 */} - setYearFilter({ ...yearFilter, start: range.start, end: range.end })} - startLabel="승인년도" endLabel="" - /> - {/* 조회 버튼 */} -
-
-
- {/* 테이블2 영역 (가로/세로 스크롤 가능) */} -
- - { - return (<>조회 결과); - })() - } - tableData={tableData} - headlist={reportTableHeadlist} - pageSize={10} - total={tableCount} - cellClick={handleReportCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - /> -
+
+ + 조회 결과} + tableData={tableData} + headlist={reportTableHeadlist} + pageSize={10} + total={tableCount} + cellClick={handleReportCellClick} + cellClassName={(row, headId) => { + if (headId !== 'fileName') return ''; + if (!row.fileSn) return '!text-gray-400'; + return '!text-left !text-blue-600 underline underline-offset-4 hover:!text-blue-800'; + }} + onPageChange={setCurPage} + resetPageNo={isResetPage} + resetSelection={isResetSelection} + />
); }; export default React.memo(ReportTab); + + + + diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 809647bf6..2fda54625 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -1,4 +1,4 @@ -/** +/** * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 * * 📄 Search Modal @@ -16,21 +16,23 @@ import LoadingOverlay from '@/components/ui/LoadingBar'; import { UITab, UiTable } from '@common/business_components/ui'; import { Button } from '@common/components/ui'; import React, { useEffect, useMemo, useRef } from 'react'; -import { Toaster } from "react-hot-toast"; +import { Toaster } from 'react-hot-toast'; import useSearchModalStore from '@/store/useSearchModalStore'; import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table'; import BasicTab, { BASIC_TYPES } from './basicTab'; import OperationTab from './operationTab'; +import ReportTab from './reportTab'; import useSearchModal from './useSearchModal'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; type SearchModalProps = { - renderMode?: "launcher" | "popup"; + renderMode?: 'launcher' | 'popup'; + autoSearchReady?: boolean; }; -const SearchModal: React.FC = ({ renderMode = "launcher" }) => { +const SearchModal: React.FC = ({ renderMode = 'launcher', autoSearchReady = false }) => { const { showTable, isSearchModalOpen, setIsSearchModalOpen, @@ -64,39 +66,38 @@ const SearchModal: React.FC = ({ renderMode = "launcher" }) => operationResultExcelDownload, handleOperationPageNation } = useOperationTab(); - const hasAutoOpenedReportRef = useRef(false); - + const hasAutoSearchedRef = useRef(false); + const hasInitializedTabAutoSearchRef = useRef(false); const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { - if (activeTab === 'basic') { // 기본정보 - if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;// 하수관로 - if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;// 하수처리시설 - if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; // 하수처리구역 - if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; // 하수맨홀 - if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; // 하수펌프장 - if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; // 우수토실 - + if (activeTab === 'basic') { + if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2; + if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2; + if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; + if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; + if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; + if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; return []; } - if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; // 유지관리 정보 + if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; return []; - }, [activeTab, basicType, workType]); + }, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]); const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => { if (activeTab === 'operation') { - if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; // 보수 및 갱신 - if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; // 점검 및 진단 - if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; // 청소 및 준설 + if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; + if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; + if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; } return []; - }, [activeTab, workType]); + }, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]); useEffect(() => { - if (renderMode !== "launcher" || !isSearchModalOpen) return; + if (renderMode !== 'launcher' || !isSearchModalOpen) return; - const popupWindow = openWindowPopup("mapSearch", { + const popupWindow = openWindowPopup('mapSearch', { sourcePath: window.location.pathname, }); @@ -105,52 +106,49 @@ const SearchModal: React.FC = ({ renderMode = "launcher" }) => } setIsSearchModalOpen(false); - }, [renderMode, isSearchModalOpen]); - - const openReportTabPopup = () => { - const popupWindow = openWindowPopup("reportTab", { - sourcePath: window.location.pathname, - }); - - if (!popupWindow) { - alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.'); - } - }; - - useEffect(() => { - if (activeTab !== 'report') { - hasAutoOpenedReportRef.current = false; - return; - } - - if (hasAutoOpenedReportRef.current) return; - - openReportTabPopup(); - hasAutoOpenedReportRef.current = true; - }, [activeTab]); + }, [isSearchModalOpen, renderMode, setIsSearchModalOpen]); useEffect(() => { resetFilters(); + }, [activeTab, basicType, workType, resetFilters]); + + useEffect(() => { + if (renderMode !== 'popup' || !autoSearchReady || hasAutoSearchedRef.current) return; + + hasAutoSearchedRef.current = true; + hasInitializedTabAutoSearchRef.current = true; + void fetchFilteredData(); + }, [autoSearchReady, fetchFilteredData, renderMode]); + + useEffect(() => { + if (renderMode !== 'popup' || !autoSearchReady) return; + if (!hasInitializedTabAutoSearchRef.current) return; + if (activeTab === 'report') return; + + setCurPage(1); + setIsResetPage(true); + setIsResetDetailPage(true); + void fetchFilteredData(); }, [activeTab, basicType, workType]); - // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetPage(false); setIsResetDetailPage(true); setDetailBizId(0); - if (showTable) fetchFilteredData(); + if (showTable) { + void fetchFilteredData(); + } }, [curPage]); - // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetDetailSelection(false); - if (showTable) handleOperationPageNation(Number(detailBizId)); + if (showTable) { + void handleOperationPageNation(Number(detailBizId)); + } }, [detailCurPage]); - const renderBody = () => ( <> - {/* 탭 영역 */} ({ @@ -159,69 +157,47 @@ const SearchModal: React.FC = ({ renderMode = "launcher" }) => }))} onValueChange={(value: string) => setActiveTab(value)} /> - {activeTab === 'basic' && ()} {/* 기본정보 탭 */} - {activeTab === 'operation' && ()} {/* 유지관리 정보 탭 */} - {activeTab === 'report' && ( -
-
-

- 하수도정비 기본계획보고서 -

-

- 보고서 상세는 모달 내부 탭 대신 -
- 전용 윈도우 팝업으로 열리도록 전환되었습니다. -

- -
-
- )} {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'basic' && ()} + {activeTab === 'operation' && ()} + {activeTab === 'report' && ()} - - {/* 📋 결과 리스트 */} - {/* 기본정보 탭 */} {activeTab === 'basic' && ( -
+
- { - const label = BASIC_TYPES.find((type) => type.val === basicType)?.text - return (<>{label} 조회 결과); - })() - } - tableData={tableData} - headlist={dynamicHeadlist} - pageSize={10} - total={tableCount} - cellClick={handleBasicCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - curPage={curPage} - headButton={ -
- - -
- } - /> + { + const label = BASIC_TYPES.find((type) => type.val === basicType)?.text; + return (<>{label} 조회 결과); + })()} + tableData={tableData} + headlist={dynamicHeadlist} + pageSize={10} + total={tableCount} + cellClick={handleBasicCellClick} + onPageChange={setCurPage} + resetPageNo={isResetPage} + resetSelection={isResetSelection} + curPage={curPage} + headButton={ +
+ + +
+ } + />
)} - {/* 유지관리 정보 탭 */} + {activeTab === 'operation' && ( <> -
- {/*
*/} +
{ - const label = WORK_TYPES.find((type) => type.val === workType)?.text - return (<>{label} 사업 현황); - })() - } + headName={(() => { + const label = WORK_TYPES.find((type) => type.val === workType)?.text; + return (<>{label} 사업 현황); + })()} tableData={tableData} headlist={dynamicHeadlist} pageSize={OPERATION_TABLE_SIZE} @@ -235,20 +211,15 @@ const SearchModal: React.FC = ({ renderMode = "launcher" }) => } /> - {/*
*/}
- {/* 테이블2 영역 */} - {/*
*/} { - const label = WORK_TYPES.find((type) => type.val === workType)?.text - return (<>{label} 결과표); - })() - } + headName={(() => { + const label = WORK_TYPES.find((type) => type.val === workType)?.text; + return (<>{label} 결과표); + })()} tableData={detailTableData} headlist={dynamicHeadlist2} pageSize={OPERATION_TABLE_SIZE} @@ -265,26 +236,26 @@ const SearchModal: React.FC = ({ renderMode = "launcher" }) =>
} /> - {/*
*/}
)} ); - if (renderMode === "launcher") { + if (renderMode === 'launcher') { return null; } return ( -
-

관망도 검색

-
+
+

관망도 검색

+
{renderBody()}
- +
); }; export default React.memo(SearchModal); + diff --git a/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx b/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx index aade9662a..c176dda2a 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx @@ -97,10 +97,10 @@ const SwageMhl: React.FC = () => { endLabel="" /> {/* 용도 */} - + { selectContentClass="z-[101]" /> {/* 하수맨홀 구경 */} - + { ); }; -export default React.memo(SwageMhl); \ No newline at end of file +export default React.memo(SwageMhl); diff --git a/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx b/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx index 9378c779b..4615a7c60 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx @@ -102,10 +102,10 @@ const SwagePpl: React.FC = () => { endLabel="" /> {/* 용도 */} - + { selectContentClass="z-[101]" /> */} {/* 관경 */} - + { ); }; -export default React.memo(SwagePpl); \ No newline at end of file +export default React.memo(SwagePpl); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts index 8ffe06de8..4a2bd5a5b 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts @@ -1,4 +1,4 @@ -/** +/** * 📄 useReportTab Hook * 검색 모달 보고서 탭 훅 * @@ -40,6 +40,15 @@ export type ReportDataFile = { filePath: string; } +const getStringValue = (value: string | number | undefined) => String(value ?? ''); + +const buildDisplayFileName = (files: ReportDataFile[]) => { + if (files.length === 0) return '-'; + if (files.length === 1) return files[0]?.fileTitle ?? '-'; + + return `${files[0]?.fileTitle ?? '대표 파일'} 외 ${files.length - 1}건`; +}; + export default function useReportTab() { const REPORT_TYPES = [ { val: '0', text: '전체' }, @@ -59,11 +68,11 @@ export default function useReportTab() { */ const flattenFolders = (data: Folder[]): FlattenedFolder[] => { const result: FlattenedFolder[] = []; - const exceptNodeNm = ["부분", "통합", "전체"]; - const addNodeNm = ["보고서", "요약보고서", "부록", "기타"]; // level5 + const exceptNodeNm = ['부분', '통합', '전체']; + const addNodeNm = ['보고서', '요약보고서', '부록', '기타']; // level5 const columnNms: { [key: number]: string } = { - 0: "root", 1: "sido", 2: "sigungu", 3: "approvalYear" - , 4: "plantZoneGubun", 5: "reportGubun", 7: "fileName", 8: "regDate" + 0: 'root', 1: 'sido', 2: 'sigungu', 3: 'approvalYear', + 4: 'plantZoneGubun', 5: 'reportGubun', 7: 'fileName', 8: 'regDate' }; const traverse = ( @@ -92,28 +101,26 @@ export default function useReportTab() { newPath.clsfSystmSn = node.clsfSystmSn; // 파일 조회를 위한 고유번호 result.push(newPath); } else { - if (level === 4) { - if (exceptNodeNm.includes(node.clsfSystmNm)) { - level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함. - } + if (level === 4 && exceptNodeNm.includes(node.clsfSystmNm)) { + level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함. } node.children.forEach((child) => { traverse(child, newPath, level + 1); }); } - } + }; - if (data && data.length > 0 && data[0]?.clsfSystmNm == "기본계획") { // data가 비어있지 않고 children이 있는 경우에만 처리 + if (data && data.length > 0 && data[0]?.clsfSystmNm == '기본계획') { data.forEach((root) => traverse(root, {}, 0)); } else { data.forEach((root) => traverse(root, {}, 1)); } return result; - } + }; const getDcFiles = async (clsfSystmSn: string): Promise => { if (!clsfSystmSn) { - console.error("clsfSystmSn is required to get files."); + console.error('clsfSystmSn is required to get files.'); return []; } const queryString = 'clsfSystmSn=' + clsfSystmSn; @@ -126,29 +133,54 @@ export default function useReportTab() { const fileReportData = await Promise.all( data.map(async (item) => { const fileArr: ReportDataFile[] = await getDcFiles(String(item.clsfSystmSn)); - let result: FlattenedFolder; - if (fileArr.length > 0) { - const f = fileArr[0]; - const fCount = fileArr.length; - result = { ...item, fileName: f?.fileTitle + " 외 " + fCount + "건", regDate: f?.regDt, fileSn: f?.fileSn, filePath: f?.filePath }; - } else { - result = { ...item, fileName: "", regDate: "", fileSn: "", filePath: "" }; + if (fileArr.length === 0) { + return { + ...item, + fileName: '-', + regDate: '', + fileSn: '', + filePath: '', + fileCount: 0, + downloadFileTitle: '', + }; } - return result; + + const primaryFile = fileArr[0]; + return { + ...item, + fileName: buildDisplayFileName(fileArr), + regDate: primaryFile?.regDt ?? '', + fileSn: primaryFile?.fileSn ?? '', + filePath: primaryFile?.filePath ?? '', + fileCount: fileArr.length, + downloadFileTitle: primaryFile?.fileTitle ?? '', + }; }) ); return fileReportData; }; const getDcFileDownload = async (fileSn: string, fileName: string, filePath: string) => { - if (filePath === "") { + if (filePath === '') { alert(`filePath 정보가 없으므로 다운로드가 불가합니다.\nfilesn: ${fileSn}`); return; } const queryString = 'filePath=' + encodeURIComponent(filePath); - const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, "blob"); + const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, 'blob'); fileDownload(responseBlob, fileName); - } + }; + + const downloadReportFile = async (item: FlattenedFolder) => { + const fileSn = getStringValue(item.fileSn); + const filePath = getStringValue(item.filePath); + const downloadFileTitle = getStringValue(item.downloadFileTitle || item.fileName); + + if (!fileSn || !filePath || !downloadFileTitle || downloadFileTitle === '-') { + return; + } + + await getDcFileDownload(fileSn, downloadFileTitle, filePath); + }; // 파일 목록 조회 const getFiles = async () => { @@ -159,7 +191,7 @@ export default function useReportTab() { data = data?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); setFiles(data); } catch (error) { - console.error("파일 목록 조회 에러:", error); + console.error('파일 목록 조회 에러:', error); } finally { setIsLoading(false); // 로딩 종료 } @@ -167,10 +199,10 @@ export default function useReportTab() { // cellClick 핸들러 const handleCellClick = (index: number | undefined) => { - if (typeof index === "number" && files[index]) { + if (typeof index === 'number' && files[index]) { const fileName = files[index].fileTitle; const filePath = files[index].filePath; - if (files[index].fileSn !== "") { + if (files[index].fileSn !== '') { getDcFileDownload(files[index].fileSn, fileName, filePath); } } @@ -182,7 +214,8 @@ export default function useReportTab() { addFileName, getDcFiles, getDcFileDownload, + downloadReportFile, getFiles, handleCellClick }; -} +} diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 585885521..578c3ac2a 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -1,4 +1,4 @@ -/** +/** * 📄 useSearchModal Hook * 검색 모달 훅 * @@ -12,10 +12,10 @@ * 🔗 사용 API * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) */ -import { getData } from "@/components/lib/api"; +import { getData } from '@/components/lib/api'; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import useStore from '../../../store/useStore'; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import GisApp from '@common/business_components/ui/map/lib/gis.core'; import useSearchModalStore from '../../../store/useSearchModalStore'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; @@ -23,61 +23,78 @@ import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessag import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const isPopupWindow = () => - typeof window !== "undefined" && + typeof window !== 'undefined' && !!window.opener && - window.location.pathname.startsWith("/popup/"); + window.location.pathname.startsWith('/popup/'); export default function useSearchModal() { const { - activeTab, workType, - tableData, detailTableData, - curPage, - var1, var2, - yearFilter, - todayYear, todayMonth, todayDate, + activeTab, + tableData, + detailTableData, setIsSearchModalOpen, setShowTable, - setTableData, setDetailTableData, - setTableCount, setDetailTableCount, + setTableData, setCurPage, setIsLoading, - setIsResetPage, setIsResetDetailPage, - setIsResetSelection, setIsResetDetailSelection, - getDateString + setIsResetPage, + setIsResetDetailPage, + setIsResetSelection, + setIsResetDetailSelection, + getDateString, } = useSearchModalStore(); const { fetchBasicFilteredData } = useBasicTab(); const { OPERATION_TABLE_SIZE } = useOperationTab(); const { BIZ_TY_CD } = useStore(); const TABS = [ - { val: "basic", text: "기본 정보" }, - { val: "operation", text: "유지관리 정보" }, - { val: "report", text: "하수도 정비 기본계획 보고서" }, + { val: 'basic', text: '기본 정보' }, + { val: 'operation', text: '유지관리 정보' }, + { val: 'report', text: '하수도 정비 기본계획 보고서' }, ]; // 검색 API 호출 함수 const fetchFilteredData = async () => { - setIsLoading(true); // 로딩 시작 - setIsResetSelection(true); // 선택 초기화 - setIsResetDetailSelection(true); // 선택 초기화 + const { + activeTab: currentTab, + curPage, + var1, + var2, + yearFilter, + workType, + todayYear, + todayMonth, + todayDate, + setTableData, + setDetailTableData, + setTableCount, + setDetailTableCount, + setShowTable, + setIsLoading, + setIsResetSelection, + setIsResetDetailSelection, + } = useSearchModalStore.getState(); + + setIsLoading(true); + setIsResetSelection(true); + setIsResetDetailSelection(true); try { - if (activeTab === 'basic') { - fetchBasicFilteredData(); - - } else if (activeTab === 'operation') { - const RequestDTO: Record = { + if (currentTab === 'basic') { + await fetchBasicFilteredData(); + } else if (currentTab === 'operation') { + const requestDto: Record = { page: `${curPage - 1}`, size: `${OPERATION_TABLE_SIZE}`, }; - if (var1) RequestDTO.bizInfoNm = var1; - if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; - // 수행기간 범위 + if (var1) requestDto.bizInfoNm = var1; + if (var2 !== '0' && var2) requestDto.bizTypeCd = var2; + const startDate = getDateString( yearFilter.start, '', '', - "19000101", + '19000101', ); const endDate = getDateString( yearFilter.end, @@ -85,22 +102,19 @@ export default function useSearchModal() { '', `${todayYear}${todayMonth}${todayDate}`, ); - RequestDTO.fromflfmtEndYmd = startDate; - RequestDTO.toflfmtEndYmd = endDate; - - // JSON.parse 사용으로 문자열 -> 배열 변환 - RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - - const params = new URLSearchParams(RequestDTO).toString(); + requestDto.fromflfmtEndYmd = startDate; + requestDto.toflfmtEndYmd = endDate; + requestDto.workTypes = JSON.parse(workType.replace(/'/g, '"')); + const params = new URLSearchParams(requestDto).toString(); const res = await getData(`v1/bizInfo?${params}`); const mappedContent = (res.content || []).map((item: any, index: number) => { const formattedItem = formatDisplayData(item); - const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); + const matched = BIZ_TY_CD.find((code) => code.lwpoCd === item.bizTypeCd); return { ...formattedItem, - rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가 + rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, }; }); @@ -118,20 +132,19 @@ export default function useSearchModal() { setTableData([]); setIsResetSelection(false); } finally { - setIsLoading(false); // 로딩 종료 + setIsLoading(false); setIsResetSelection(false); - // setIsResetDetailSelection(false); } }; // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 const handleSearch = () => { - setCurPage(1); // 페이지를 1로 초기화 - fetchFilteredData(); - setIsResetPage(true); // 페이지를 초기화 - setIsResetDetailPage(true); // 디테일 페이지를 초기화 - setIsResetSelection(false); // 선택 초기화 - setIsResetDetailSelection(false); // 선택 초기화 + setCurPage(1); + void fetchFilteredData(); + setIsResetPage(true); + setIsResetDetailPage(true); + setIsResetSelection(false); + setIsResetDetailSelection(false); setShowTable(true); }; @@ -186,7 +199,7 @@ export default function useSearchModal() { setIsSearchModalOpen(false); } } - } + }; return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData }; -}; +} diff --git a/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx index 4ea769ff7..8b03a82e8 100644 --- a/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx +++ b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx @@ -20,12 +20,109 @@ export const WATER_DEPTH_LAYER_TYPES: string[] = [ 'tb_ms_sbmrg_dngr_map' // 침수위험지도 ]; +export type WaterDepthLegendItem = { + key: string; + label: string; + color: string; + borderColor: string; + sortSeq: number; +}; + +const getLayerTypeName = (layerInfo: any) => + String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim(); + +const getFiniteSortOrder = (value: unknown) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +}; + +const getLegendNumericOrder = (label: string) => { + const normalized = String(label).trim(); + const rangeMatch = normalized.match(/^(\d+(?:\.\d+)?)~(\d+(?:\.\d+)?)m/); + if (rangeMatch) { + return { + start: Number(rangeMatch[1]), + end: Number(rangeMatch[2]), + priority: 0, + }; + } + + const overMatch = normalized.match(/^(\d+(?:\.\d+)?)m\s*이상/); + if (overMatch) { + const value = Number(overMatch[1]); + return { + start: value, + end: value, + priority: 1, + }; + } + + return { + start: Number.MAX_SAFE_INTEGER, + end: Number.MAX_SAFE_INTEGER, + priority: 2, + }; +}; + +const getLegendColor = (layerInfo: any) => + String(layerInfo?.polgnColor ?? layerInfo?.spacColor ?? layerInfo?.lnColor ?? layerInfo?.lineColor ?? "").trim(); + +const getLegendBorderColor = (layerInfo: any) => + String(layerInfo?.lnColor ?? layerInfo?.lineColor ?? layerInfo?.polgnColor ?? layerInfo?.spacColor ?? "").trim(); + +const hasLegendStyle = (layerInfo: any) => getLegendColor(layerInfo) !== "" || getLegendBorderColor(layerInfo) !== ""; + +const normalizeLegendLabel = (label: string) => { + const normalized = String(label).trim(); + return normalized.endsWith("미만") ? normalized : `${normalized} 미만`; +}; + +export const extractWaterDepthLegendItems = (checkedNodesData: any[]): WaterDepthLegendItem[] => { + if (!Array.isArray(checkedNodesData) || checkedNodesData.length === 0) return []; + + const items = new Map(); + + checkedNodesData.forEach((node) => { + const layerInfo = node?.data ?? {}; + const layerTypeName = getLayerTypeName(layerInfo); + if (!WATER_DEPTH_LAYER_TYPES.includes(layerTypeName)) return; + if (!hasLegendStyle(layerInfo)) return; + + const itemKey = String(node?.text ?? layerInfo?.lyrNm ?? "").trim(); + const itemColor = getLegendColor(layerInfo); + const itemBorderColor = getLegendBorderColor(layerInfo); + + if (!itemKey || !itemColor) return; + if (!items.has(itemKey)) { + items.set(itemKey, { + key: itemKey, + label: normalizeLegendLabel(itemKey), + color: itemColor, + borderColor: itemBorderColor || itemColor, + sortSeq: getFiniteSortOrder(layerInfo?.sortSeq), + }); + } + }); + + return Array.from(items.values()).sort((left, right) => { + const leftOrder = getLegendNumericOrder(left.key); + const rightOrder = getLegendNumericOrder(right.key); + + if (leftOrder.start !== rightOrder.start) return leftOrder.start - rightOrder.start; + if (leftOrder.priority !== rightOrder.priority) return leftOrder.priority - rightOrder.priority; + if (leftOrder.end !== rightOrder.end) return leftOrder.end - rightOrder.end; + if (left.sortSeq !== right.sortSeq) return left.sortSeq - right.sortSeq; + return left.label.localeCompare(right.label, "ko"); + }); +}; + interface WaterDepthProps { onClose?: () => void; isPlaying: boolean; setIsPlaying: (isPlaying: boolean) => void; totalSteps?: number; currentStep?: number; + legendItems?: WaterDepthLegendItem[]; } export function WaterDepth({ @@ -34,6 +131,7 @@ export function WaterDepth({ setIsPlaying, totalSteps = 0, currentStep = 0, + legendItems = [], }: WaterDepthProps) { useEffect(() => { @@ -70,13 +168,16 @@ export function WaterDepth({
-
침 수 심
+
침수심 범례
- {/* 코드: N330 */} - {/* 코드: N331 */} - {/* 코드: N332 */} - {/* 코드: N333 */} - {/* 코드: N334 */} + {legendItems.map((item) => ( + + ))}
@@ -85,14 +186,14 @@ export function WaterDepth({ ); } -function LegendItem({ color, label }: { color: string; label: string }) { +function LegendItem({ color, borderColor, label }: { color: string; borderColor: string; label: string }) { return (
{label}
); -} \ No newline at end of file +} diff --git a/apps/sn/app/(map-layout)/map2/page.tsx b/apps/sn/app/(map-layout)/map2/page.tsx new file mode 100644 index 000000000..972464ec9 --- /dev/null +++ b/apps/sn/app/(map-layout)/map2/page.tsx @@ -0,0 +1 @@ +export { default } from "../map/page"; diff --git a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx index 43648114c..702c5beb2 100644 --- a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx +++ b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx @@ -49,8 +49,10 @@ type BaseLayout = { onSubmitEvent: Function graphBtn?: boolean analysisBtn?: string + resultViewBtn?: string firstDivBtn?: boolean selectBox?: SelectBox + selectionComponent?: ReactNode showDatePicker?: boolean; ctpvCd: string; sggCd: string; @@ -66,10 +68,12 @@ type BaseLayout = { setSggCd: (sgg: string) => void; setSggName?: (sggName: string) => void; setSwtrmtFcltNm?: (swtrmtFcltNm: string) => void; + defaultSwtrmtFcltNm?: string; setIsLoading?: (isLoading: boolean) => void; onExcelDownload?: () => void; onDeleteFile?: () => void; onAnalysis?: () => void; + onResultView?: () => void; selectedRowData?: any; isIr?: boolean; isRdii?: boolean; @@ -82,7 +86,9 @@ const BaseLayout = ({ onSubmitEvent, graphBtn = false, analysisBtn, + resultViewBtn, selectBox, + selectionComponent, showDatePicker = false, ctpvCd, sggCd, @@ -98,10 +104,12 @@ const BaseLayout = ({ setSggCd, setSggName, setSwtrmtFcltNm, + defaultSwtrmtFcltNm, setIsLoading, onExcelDownload, onDeleteFile, onAnalysis, + onResultView, isIr = false, isRdii = false, }: BaseLayout) => { @@ -191,21 +199,25 @@ const BaseLayout = ({ /> )} - { - setCtpvCd(sidoId); - setSggCd('0'); - }} - onSigunguChange={setSggCd} - setSggName={setSggName} - selectedSwagePrcsFclt={selectedSwagePrcsFclt} - onSwagePrcsFcltChange={onSwagePrcsFcltChange} - setSwtrmtFcltNm={setSwtrmtFcltNm} - setIsLoading={setIsLoading} - /> + {/* 침입수 분석 실행처럼 선택 구조가 다른 화면은 커스텀 selector를 주입하고, 나머지는 기존 시설 selector를 유지한다. */} + {selectionComponent ?? ( + { + setCtpvCd(sidoId); + setSggCd('0'); + }} + onSigunguChange={setSggCd} + setSggName={setSggName} + selectedSwagePrcsFclt={selectedSwagePrcsFclt} + onSwagePrcsFcltChange={onSwagePrcsFcltChange} + setSwtrmtFcltNm={setSwtrmtFcltNm} + defaultSwtrmtFcltNm={defaultSwtrmtFcltNm} + setIsLoading={setIsLoading} + /> + )} {selectBox?.searchChk && (
@@ -246,6 +258,15 @@ const BaseLayout = ({ {analysisBtn && ( )} + {resultViewBtn && ( + + )}
diff --git a/apps/sn/app/(with-base-layout)/ii/analysisResultView.ts b/apps/sn/app/(with-base-layout)/ii/analysisResultView.ts new file mode 100644 index 000000000..35c2bec26 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/ii/analysisResultView.ts @@ -0,0 +1,31 @@ +"use client"; + +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; + +type AnalysisResultPopupKey = "inftwtResultPopup" | "rdiiResultPopup"; + +const ANALYSIS_RESULT_MAP_WINDOW_NAME = "sn-analysis-result-map"; + +export const openAnalysisResultMapView = (popupKey: AnalysisResultPopupKey) => { + if (typeof window === "undefined") return; + + const mapUrl = `/map?analysisPopupKey=${encodeURIComponent(popupKey)}`; + const mapWindow = window.open(mapUrl, ANALYSIS_RESULT_MAP_WINDOW_NAME); + + if (!mapWindow) { + alert("하수관망도 창이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요."); + return; + } + + // 분석 화면에서 직접 결과 popup을 열면 opener는 map이 아니라 현재 화면이 된다. + // map 창은 별도 탭으로 띄우고, 결과 popup의 row 선택은 storage 이벤트로 다시 map에 전달한다. + const popupWindow = openWindowPopup(popupKey); + mapWindow.focus(); + + if (!popupWindow) { + alert("분석결과 popup이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요."); + return; + } + + popupWindow.focus(); +}; diff --git a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx index e2a23759b..858c5a9d8 100644 --- a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx @@ -28,6 +28,8 @@ import { useForm } from 'react-hook-form' import { z } from 'zod' import { excelToFlowRateData, excelToMatchingResultData, excelToTimePatternData, excelToRcptrInfoData } from '@/components/lib/utils' import BaseLayout from '../BaseLayout' +import { useAnalysisCompletionWatcher } from '../useAnalysisCompletionWatcher' +import { openAnalysisResultMapView } from '../analysisResultView' import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig } from '../../fileValidation'; import { validateBasicSelectionsWithAlert } from '../../selectionValidation'; import { tokenManager } from '@/entities/auth/lib/token-manager'; @@ -58,17 +60,21 @@ interface UploadedFileInfo { } // 초기 파일 정보 상태 const initialFileInfo: UploadedFileInfo = { file: null, atflCd: null, atflId: null }; +const DEFAULT_CTPV_CD = '41'; +const DEFAULT_SGG_CD = '41830'; +const DEFAULT_SGG_NAME = '\uC591\uD3C9\uAD70'; +const DEFAULT_SWTRMT_FCLT_NM = '\uC591\uD3C9'; const IrSetting = () => { const [input1, setInput1] = useState('0.8'); // 오수전환율 초기값 const [input2, setInput2] = useState(''); - const [select1, setSelect1] = useState('0'); // 하수처리장코드 + const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState('0'); // ?????? ?? ?? const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태 const [isLoadingResult, setIsLoadingResult] = useState(false); // 로딩 상태 - const [ctpvCd, setCtpvCd] = useState('0'); // 시도 코드 상태 - const [sggCd, setSggCd] = useState('0'); // 시군구 코드 상태 - const [sggName, setSggName] = useState(''); // 시군구 이름 상태 - const [swtrmtFcltNm, setSwtrmtFcltNm] = useState(''); // 하수처리시설명 상태 + const [ctpvCd, setCtpvCd] = useState(DEFAULT_CTPV_CD); // 시도 코드 상태 + const [sggCd, setSggCd] = useState(DEFAULT_SGG_CD); // 시군구 코드 상태 + const [sggName, setSggName] = useState(DEFAULT_SGG_NAME); // 시군구 이름 상태 + const [swtrmtFcltNm, setSwtrmtFcltNm] = useState(DEFAULT_SWTRMT_FCLT_NM); // 하수처리시설명 상태 const [uploadedFilesData, setUploadedFilesData] = useState([ initialFileInfo, // 0: 오수관망도 initialFileInfo, // 1: 수용가-맨홀 매칭결과 @@ -89,6 +95,17 @@ const IrSetting = () => { enabled: true, }); + const { startWatching } = useAnalysisCompletionWatcher({ + buildStatusUrl: (analysisSn) => `v1/inftwtAnls/${analysisSn}/status`, + successMessage: '침입수 분석이 완료되었습니다.', + failureMessage: '침입수 분석이 실패했습니다. 로그를 확인해주세요.', + analysisType: 'INFTWT', + analysisName: '침입수 분석', + initialProgressPercent: 10, + initialProgressStep: 'REQUEST_ACCEPTED', + initialProgressMessage: '요청 접수 완료', + }); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -126,8 +143,11 @@ const IrSetting = () => { setSelectedDate(`${year}${month}`); setInput1(''); // 입력 필드 초기화 setInput2(''); // 입력 필드 초기화 - setSelect1('0'); - setSwtrmtFcltNm(''); + setSelectedSwagePrcsFclt('0'); + setCtpvCd(DEFAULT_CTPV_CD); + setSggCd(DEFAULT_SGG_CD); + setSggName(DEFAULT_SGG_NAME); + setSwtrmtFcltNm(DEFAULT_SWTRMT_FCLT_NM); setFormResetKey(prevKey => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경 }; @@ -151,16 +171,21 @@ const IrSetting = () => { const registFile = async (analysisData: any) => { - setIsLoadingResult(true); const data = await postMutiPart('v1/inftwtAnls/savewithfile', analysisData); - if (data) { - setIsLoadingResult(false); - alert("분석 데이터가 성공적으로 제출되었습니다."); - resetAllForms(); // 성공 시 폼 초기화 함수 호출 - } else { - setIsLoadingResult(false); - alert("분석 데이터 제출 중 오류가 발생했습니다."); + if (!data) { + throw new Error('분석 요청 접수 응답이 비어 있습니다.'); } + + const nextAnalysisSn = Number(data?.inftwtAnlsSn ?? 0); + setIsLoadingResult(false); + await new Promise((resolve) => setTimeout(resolve, 0)); + alert("분석 데이터가 성공적으로 제출되었습니다."); + + if (Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0) { + startWatching(nextAnalysisSn); + } + + resetAllForms(); }; // 침입수 분석 실행 버튼 클릭 핸들러 @@ -168,11 +193,12 @@ const IrSetting = () => { setIsLoadingResult(true); // 로딩 상태 시작 // 기본 선택 항목들 검증 (환경청, 시도, 시군구, 하수처리장) - if (!validateBasicSelectionsWithAlert(select1, { + if (!validateBasicSelectionsWithAlert(selectedSwagePrcsFclt, { requireEnv: true, requireSido: true, requireSigungu: true, - requireTreatmentPlant: true + requireTreatmentPlant: true, + treatmentPlantLabel: '하수처리시설' })) { setIsLoadingResult(false); return; @@ -302,8 +328,8 @@ const IrSetting = () => { ); // 각 파싱 결과 접근 - const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData; - const timePatternData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData; + const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData ?? []; + const timePatternData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData ?? []; const flowRateData = file4?.file ? parsedResults.find(r => r && r.fileName === file4.file?.name)?.parsedData : null; @@ -311,6 +337,23 @@ const IrSetting = () => { ? parsedResults.find(r => r && r.fileName === file5.file?.name)?.parsedData : null; + if (!swtrmtFcltNm || !swtrmtFcltNm.trim()) { + setIsLoadingResult(false); + alert('하수처리시설명이 비어 있습니다. 시설을 다시 선택해주세요.'); + return; + } + + if (!Array.isArray(matchingResultData) || matchingResultData.length === 0) { + setIsLoadingResult(false); + alert('수용가-맨홀 매칭결과 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.'); + return; + } + + if (!Array.isArray(timePatternData) || timePatternData.length === 0) { + setIsLoadingResult(false); + alert('오수량 시간패턴 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.'); + return; + } // 새로운 JSON 데이터 생성 const jsonData = { ctpvCd: ctpvCd !== '0' ? String(ctpvCd).trim() : null, @@ -344,8 +387,10 @@ const IrSetting = () => { setIsLoadingResult(false); } catch (error) { setIsLoadingResult(false); - console.error("파일 파싱 중 오류 발생:", error); - alert("파일 처리 중 오류가 발생했습니다."); + console.error("침입수 분석 요청 처리 중 오류 발생:", error); + alert(error instanceof Error && error.message.trim() + ? error.message + : "파일 처리 중 오류가 발생했습니다."); return; } } @@ -359,18 +404,21 @@ const IrSetting = () => { form={form} onSubmitEvent={() => { }} graphBtn={false} + resultViewBtn={'결과보기'} analysisBtn={'침입수분석 실행'} selectBox={{ title: '분석 대상지역 선택' }} ctpvCd={ctpvCd} sggCd={sggCd} sggName={sggName} - selectedSwagePrcsFclt={select1} - onSwagePrcsFcltChange={setSelect1} + selectedSwagePrcsFclt={selectedSwagePrcsFclt} + onSwagePrcsFcltChange={setSelectedSwagePrcsFclt} setCtpvCd={setCtpvCd} setSggCd={setSggCd} setSggName={setSggName} setSwtrmtFcltNm={setSwtrmtFcltNm} + defaultSwtrmtFcltNm={swtrmtFcltNm} setIsLoading={setIsLoadingResult} + onResultView={() => openAnalysisResultMapView('inftwtResultPopup')} onAnalysis={handleAnalysisClick} isIr={true} > @@ -494,4 +542,4 @@ const IrSetting = () => { ) } -export default IrSetting \ No newline at end of file +export default IrSetting diff --git a/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx index 4fd6d947a..70d1080a7 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx @@ -13,7 +13,7 @@ * - 로딩 오버레이 및 에러 핸들링 지원 * * 🔗 사용 API - * - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/savewithfile (sewerage-service) + * - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service) * - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service) */ "use client" @@ -22,6 +22,8 @@ import { postMutiPart } from '@/components/lib/api' import FileUploadForm from "@/components/ui/fileUploadForm" import FormSection from "@/components/ui/formSection" import BaseLayout from "../BaseLayout" +import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher" +import { openAnalysisResultMapView } from "../analysisResultView" import { DateMonthPicker, filePicked } from "@common/business_components/ui" import { Input } from "@common/components/ui" import { zodResolver } from "@hookform/resolvers/zod" @@ -74,9 +76,9 @@ const formSchema = z.object({ type FormSchemaType = z.infer; // 기본 폼 값 정의 (테스트 편의를 위해 초기값 설정, 추후 삭제 필요) const defaultFormValues: FormSchemaType = { - ctpvCd: "", - sggCd: "", - swtrmtFcltNm: "", + ctpvCd: "41", + sggCd: "41830", + swtrmtFcltNm: "\uC591\uD3C9", rtkSource: "", rdiiArea: "", // 문자열로 기본값 설정 unitFactors: [ @@ -112,6 +114,10 @@ interface UploadedFileInfo { // 초기 파일 정보 상태 const initialFileInfo: UploadedFileInfo = { file: null, atflCd: null, atflId: null }; +const DEFAULT_CTPV_CD = '41'; +const DEFAULT_SGG_CD = '41830'; +const DEFAULT_SGG_NAME = '\uC591\uD3C9\uAD70'; +const DEFAULT_SWTRMT_FCLT_NM = '\uC591\uD3C9'; export default function RTKForm() { const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태 @@ -126,10 +132,10 @@ export default function RTKForm() { ]); const [select1, setSelect1] = useState('0'); // 하수처리장 (swtrmtFcltCd) - const [ctpvCd, setCtpvCd] = useState('0'); // 시도 코드 상태 - const [sggCd, setSggCd] = useState('0'); // 시군구 코드 상태 - const [sggName, setSggName] = useState(''); // 시군구 이름 상태 - const [swtrmtFcltNm, setSwtrmtFcltNm] = useState(''); // 하수처리시설명 상태 + const [ctpvCd, setCtpvCd] = useState(DEFAULT_CTPV_CD); // 시도 코드 상태 + const [sggCd, setSggCd] = useState(DEFAULT_SGG_CD); // 시군구 코드 상태 + const [sggName, setSggName] = useState(DEFAULT_SGG_NAME); // 시군구 이름 상태 + const [swtrmtFcltNm, setSwtrmtFcltNm] = useState(DEFAULT_SWTRMT_FCLT_NM); // 하수처리시설명 상태 const { data: userInfo } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], @@ -137,6 +143,17 @@ export default function RTKForm() { enabled: true, }); + const { startWatching } = useAnalysisCompletionWatcher({ + buildStatusUrl: (analysisSn) => `v1/rdiiAnls/${analysisSn}/status`, + successMessage: 'RDII 분석이 완료되었습니다.', + failureMessage: 'RDII 분석이 실패했습니다. 로그를 확인해주세요.', + analysisType: 'RDII', + analysisName: 'RDII 분석', + initialProgressPercent: 10, + initialProgressStep: 'REQUEST_ACCEPTED', + initialProgressMessage: '요청 접수 완료', + }); + // form 인스턴스 생성 const form = useForm({ resolver: zodResolver(formSchema), @@ -153,6 +170,10 @@ export default function RTKForm() { ]); setFormResetKey(prevKey => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경 setSelect1('0'); + setCtpvCd(DEFAULT_CTPV_CD); + setSggCd(DEFAULT_SGG_CD); + setSggName(DEFAULT_SGG_NAME); + setSwtrmtFcltNm(DEFAULT_SWTRMT_FCLT_NM); }; // FileUploadForm의 onFileSelect prop으로 전달될 콜백 함수 @@ -174,16 +195,27 @@ export default function RTKForm() { }; const registFile = async (analysisData: any) => { - setIsLoadingResult(true); const data = await postMutiPart('v1/rdiiAnls/savewithfile', analysisData); - if (data) { - setIsLoadingResult(false); - alert("분석 데이터가 성공적으로 제출되었습니다."); - resetAllForms(); // 성공 시 폼 초기화 함수 호출 - } else { - setIsLoadingResult(false); - alert("분석 데이터 제출 중 오류가 발생했습니다."); + if (!data) { + throw new Error('분석 요청 접수 응답이 비어 있습니다.'); } + + const nextAnalysisSn = Number(data?.rainInfwtAnlsSn ?? 0); + const canWatchCompletion = Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0; + + setIsLoadingResult(false); + await new Promise((resolve) => setTimeout(resolve, 0)); + alert( + canWatchCompletion + ? '분석 요청이 접수되었습니다. 완료 시 안내됩니다.' + : '분석 요청이 접수되었습니다. 결과 목록에서 진행 상태를 확인해주세요.' + ); + + if (canWatchCompletion) { + startWatching(nextAnalysisSn); + } + + resetAllForms(); }; // 침입수 분석 실행 버튼 클릭 핸들러 @@ -372,8 +404,10 @@ export default function RTKForm() { setIsLoadingResult(false); } catch (error) { setIsLoadingResult(false); - console.error("파일 파싱 중 오류 발생:", error); - alert("파일 처리 중 오류가 발생했습니다."); + console.error("RDII 분석 요청 처리 중 오류 발생:", error); + alert(error instanceof Error && error.message.trim() + ? error.message + : "파일 처리 중 오류가 발생했습니다."); return; } } @@ -413,6 +447,7 @@ export default function RTKForm() { form={form} // react-hook-form 인스턴스 전달 onSubmitEvent={() => { }} // BaseLayout의 form 태그에 연결될 onSubmit graphBtn={false} + resultViewBtn={'결과보기'} analysisBtn={'RDII분석 실행'} ctpvCd={ctpvCd} sggCd={sggCd} @@ -423,7 +458,9 @@ export default function RTKForm() { setSggCd={setSggCd} setSggName={setSggName} setSwtrmtFcltNm={setSwtrmtFcltNm} + defaultSwtrmtFcltNm={swtrmtFcltNm} setIsLoading={setIsLoadingResult} + onResultView={() => openAnalysisResultMapView('rdiiResultPopup')} onAnalysis={handleAnalysisClick} isRdii={true} > diff --git a/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx b/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx new file mode 100644 index 000000000..8f5d98095 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { usePathname } from "next/navigation"; + +import { getRawJson } from "@/components/lib/api"; + +type AnalysisStatusResponse = { + scsYn?: string | null; + message?: string | null; + progressPercent?: number | null; + progressStep?: string | null; + progressMessage?: string | null; + updatedAt?: string | null; +}; + +type AnalysisStatusEnvelope = { + status?: number; + message?: string | null; + data?: AnalysisStatusResponse | null; +}; + +const unwrapAnalysisStatusResponse = ( + response: AnalysisStatusResponse | AnalysisStatusEnvelope | null +): AnalysisStatusResponse | null => { + if (!response || typeof response !== "object") { + return null; + } + + const candidate = response as AnalysisStatusEnvelope; + if (candidate.data && typeof candidate.data === "object") { + return candidate.data; + } + + return response as AnalysisStatusResponse; +}; + +type UseAnalysisCompletionWatcherParams = { + buildStatusUrl: (analysisSn: number) => string; + successMessage: string; + failureMessage: string; + analysisType?: string; + analysisName?: string; + intervalMs?: number; + initialProgressPercent?: number; + initialProgressStep?: string; + initialProgressMessage?: string; +}; + +type PendingAnalysisWatch = { + analysisSn: number; + analysisType: string; + analysisName: string; + failureMessage: string; + key: string; + progressMessage?: string; + progressPercent?: number; + progressStep?: string; + statusUrl: string; + successMessage: string; + updatedAt?: string | null; +}; + +const DEFAULT_ANALYSIS_TYPE = "GENERIC"; +const DEFAULT_ANALYSIS_NAME = "분석 진행"; +const DEFAULT_POLLING_INTERVAL_MS = 3000; +const DEFAULT_PROGRESS_MESSAGE = "진행 상태를 확인하는 중입니다."; +const INFTWT_ANALYSIS_TYPE = "INFTWT"; +const RDII_ANALYSIS_TYPE = "RDII"; +const PROGRESS_OVERLAY_ANALYSIS_TYPES = new Set([ + INFTWT_ANALYSIS_TYPE, + RDII_ANALYSIS_TYPE, +]); +const PENDING_ANALYSIS_WATCHES_STORAGE_KEY = "SN_PENDING_ANALYSIS_WATCHES"; +const PENDING_ANALYSIS_WATCHES_CHANGED_EVENT = "sn-pending-analysis-watches-changed"; + +const clampProgressPercent = (value?: number | null) => { + if (!Number.isFinite(value)) { + return undefined; + } + + return Math.max(0, Math.min(100, Number(value))); +}; + +const createWatchKey = (statusUrl: string, analysisSn: number) => `${statusUrl}::${analysisSn}`; + +const dispatchPendingAnalysisChanged = () => { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent(new Event(PENDING_ANALYSIS_WATCHES_CHANGED_EVENT)); +}; + +const normalizePendingAnalysisWatch = (raw: unknown): PendingAnalysisWatch | null => { + if (!raw || typeof raw !== "object") { + return null; + } + + const candidate = raw as Record; + const analysisSn = Number(candidate.analysisSn ?? 0); + const statusUrl = String(candidate.statusUrl ?? "").trim(); + if (!Number.isFinite(analysisSn) || analysisSn <= 0 || !statusUrl) { + return null; + } + + const progressPercent = clampProgressPercent( + typeof candidate.progressPercent === "number" + ? candidate.progressPercent + : Number(candidate.progressPercent) + ); + + return { + analysisSn, + analysisType: + String(candidate.analysisType ?? DEFAULT_ANALYSIS_TYPE).trim() || + DEFAULT_ANALYSIS_TYPE, + analysisName: + String(candidate.analysisName ?? DEFAULT_ANALYSIS_NAME).trim() || + DEFAULT_ANALYSIS_NAME, + failureMessage: + String(candidate.failureMessage ?? "분석이 실패했습니다.").trim() || + "분석이 실패했습니다.", + key: + String(candidate.key ?? createWatchKey(statusUrl, analysisSn)).trim() || + createWatchKey(statusUrl, analysisSn), + progressMessage: + String(candidate.progressMessage ?? "").trim() || undefined, + progressPercent, + progressStep: String(candidate.progressStep ?? "").trim() || undefined, + statusUrl, + successMessage: + String(candidate.successMessage ?? "분석이 완료되었습니다.").trim() || + "분석이 완료되었습니다.", + updatedAt: + candidate.updatedAt == null + ? undefined + : String(candidate.updatedAt).trim() || undefined, + }; +}; + +const loadPendingAnalysisWatches = (): PendingAnalysisWatch[] => { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map(normalizePendingAnalysisWatch) + .filter((item): item is PendingAnalysisWatch => item !== null); + } catch (error) { + console.error("분석 완료 감시 목록을 읽는 중 오류가 발생했습니다.", error); + return []; + } +}; + +const savePendingAnalysisWatches = (items: PendingAnalysisWatch[]) => { + if (typeof window === "undefined") { + return; + } + + if (items.length === 0) { + window.localStorage.removeItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY); + dispatchPendingAnalysisChanged(); + return; + } + + window.localStorage.setItem( + PENDING_ANALYSIS_WATCHES_STORAGE_KEY, + JSON.stringify(items) + ); + dispatchPendingAnalysisChanged(); +}; + +const replacePendingAnalysisWatch = ( + items: PendingAnalysisWatch[], + nextItem: PendingAnalysisWatch +): PendingAnalysisWatch[] => { + const filteredItems = items.filter((item) => item.key !== nextItem.key); + return [...filteredItems, nextItem]; +}; + +const toProgressText = (item: PendingAnalysisWatch) => { + return ( + item.progressMessage?.trim() || + item.progressStep?.trim() || + DEFAULT_PROGRESS_MESSAGE + ); +}; + +const toOverlayItem = (items: PendingAnalysisWatch[]) => { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item) { + continue; + } + + if (!PROGRESS_OVERLAY_ANALYSIS_TYPES.has(item.analysisType)) { + continue; + } + + if (typeof item.progressPercent !== "number") { + continue; + } + + return item; + } + + return null; +}; + +const AnalysisProgressOverlay = ({ item }: { item: PendingAnalysisWatch }) => { + const progressPercent = clampProgressPercent(item.progressPercent) ?? 0; + const progressText = toProgressText(item); + + return ( +
+
+

+ {item.analysisName} +

+ + {progressPercent}% + +
+
+
+
+

{progressText}

+
+ ); +}; + +export function useAnalysisCompletionWatcher({ + buildStatusUrl, + successMessage, + failureMessage, + analysisType = DEFAULT_ANALYSIS_TYPE, + analysisName = DEFAULT_ANALYSIS_NAME, + initialProgressPercent, + initialProgressStep, + initialProgressMessage, +}: UseAnalysisCompletionWatcherParams) { + const startWatching = (analysisSn: number) => { + if ( + typeof window === "undefined" || + !Number.isFinite(analysisSn) || + analysisSn <= 0 + ) { + return; + } + + const statusUrl = buildStatusUrl(analysisSn); + const nextItem: PendingAnalysisWatch = { + analysisSn, + analysisType, + analysisName, + failureMessage, + key: createWatchKey(statusUrl, analysisSn), + progressMessage: initialProgressMessage, + progressPercent: clampProgressPercent(initialProgressPercent), + progressStep: initialProgressStep, + statusUrl, + successMessage, + updatedAt: undefined, + }; + + const nextItems = replacePendingAnalysisWatch( + loadPendingAnalysisWatches(), + nextItem + ); + savePendingAnalysisWatches(nextItems); + }; + + const stopWatching = (analysisSn?: number) => { + if (typeof window === "undefined") { + return; + } + + if (!analysisSn) { + savePendingAnalysisWatches([]); + return; + } + + const nextItems = loadPendingAnalysisWatches().filter( + (item) => item.analysisSn !== analysisSn + ); + savePendingAnalysisWatches(nextItems); + }; + + return { + startWatching, + stopWatching, + }; +} + +export function GlobalAnalysisCompletionWatcher() { + const pathname = usePathname(); + // 전역 overlay와 완료 alert가 같은 storage 소스를 바라보도록 local state로 동기화한다. + const [pendingItems, setPendingItems] = useState([]); + + const activeOverlayItem = useMemo(() => toOverlayItem(pendingItems), [pendingItems]); + const shouldHideOverlay = pathname?.includes("/popup/") ?? false; + + useEffect(() => { + if (shouldHideOverlay) { + setPendingItems([]); + return; + } + let intervalId: number | null = null; + let isPolling = false; + + const syncPendingItems = () => { + setPendingItems(loadPendingAnalysisWatches()); + }; + + const pollPendingAnalysis = async () => { + if (isPolling || typeof window === "undefined") { + return; + } + + if (document.visibilityState !== "visible") { + syncPendingItems(); + return; + } + + const currentItems = loadPendingAnalysisWatches(); + syncPendingItems(); + if (currentItems.length === 0) { + return; + } + + isPolling = true; + + try { + for (const item of currentItems) { + const pollUrl = `${item.statusUrl}${item.statusUrl.includes("?") ? "&" : "?"}_ts=${Date.now()}`; + const rawResponse = (await getRawJson(pollUrl, { cache: "no-store" })) as AnalysisStatusResponse | AnalysisStatusEnvelope | null; + const response = unwrapAnalysisStatusResponse(rawResponse); + if (!response) { + continue; + } + + const scsYn = String(response.scsYn ?? "").trim().toUpperCase(); + const message = String(response.message ?? "").trim(); + const latestItems = loadPendingAnalysisWatches(); + const latestItem = latestItems.find( + (latestWatch) => latestWatch.key === item.key + ); + if (!latestItem) { + continue; + } + + const updatedItem: PendingAnalysisWatch = { + ...latestItem, + progressMessage: + String( + response.progressMessage ?? latestItem.progressMessage ?? "" + ).trim() || latestItem.progressMessage, + progressPercent: + clampProgressPercent( + typeof response.progressPercent === "number" + ? response.progressPercent + : Number(response.progressPercent) + ) ?? latestItem.progressPercent, + progressStep: + String(response.progressStep ?? latestItem.progressStep ?? "").trim() || + latestItem.progressStep, + updatedAt: + String(response.updatedAt ?? latestItem.updatedAt ?? "").trim() || + latestItem.updatedAt, + }; + + if (scsYn !== "Y" && scsYn !== "N") { + savePendingAnalysisWatches( + replacePendingAnalysisWatch(latestItems, updatedItem) + ); + continue; + } + + savePendingAnalysisWatches( + latestItems.filter((latestWatch) => latestWatch.key !== item.key) + ); + alert(message || (scsYn === "Y" ? item.successMessage : item.failureMessage)); + } + } catch (error) { + console.error("분석 완료 상태 조회 중 오류가 발생했습니다.", error); + } finally { + isPolling = false; + syncPendingItems(); + } + }; + + const handlePendingAnalysisChanged = () => { + syncPendingItems(); + void pollPendingAnalysis(); + }; + + intervalId = window.setInterval(() => { + void pollPendingAnalysis(); + }, DEFAULT_POLLING_INTERVAL_MS); + + window.addEventListener("storage", handlePendingAnalysisChanged); + window.addEventListener( + PENDING_ANALYSIS_WATCHES_CHANGED_EVENT, + handlePendingAnalysisChanged + ); + document.addEventListener("visibilitychange", handlePendingAnalysisChanged); + syncPendingItems(); + void pollPendingAnalysis(); + + return () => { + if (intervalId !== null) { + window.clearInterval(intervalId); + } + window.removeEventListener("storage", handlePendingAnalysisChanged); + window.removeEventListener( + PENDING_ANALYSIS_WATCHES_CHANGED_EVENT, + handlePendingAnalysisChanged + ); + document.removeEventListener("visibilitychange", handlePendingAnalysisChanged); + }; + }, [shouldHideOverlay]); + + if (shouldHideOverlay) { + return null; + } + + return activeOverlayItem ? : null; +} \ No newline at end of file diff --git a/apps/sn/app/(with-base-layout)/layout.tsx b/apps/sn/app/(with-base-layout)/layout.tsx index 346521d17..9cf3614f4 100644 --- a/apps/sn/app/(with-base-layout)/layout.tsx +++ b/apps/sn/app/(with-base-layout)/layout.tsx @@ -13,7 +13,7 @@ */ "use client"; -import React, { useEffect} from 'react'; +import React, { useEffect } from 'react'; import { usePathname, useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { BaseLayout } from "@common/components"; @@ -21,6 +21,7 @@ import type { MenuItem } from "@/entities/menu"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; import useStore from '../store/useStore'; +import { authService } from "@/entities/auth"; export default function WithBaseLayout({ children, @@ -50,10 +51,11 @@ export default function WithBaseLayout({ return ( {children} ); -} +} \ No newline at end of file diff --git a/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx b/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx new file mode 100644 index 000000000..f31f52ae7 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { CalendarIcon } from "lucide-react"; +import { useState, type ComponentProps } from "react"; + +import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from "@common/components/ui"; + +type CalendarProps = ComponentProps; + +type SearchDatePickerProps = { + selected?: Date; + onSelect: (date: Date | undefined) => void; + placeholder?: string; + className?: string; + disabled?: CalendarProps["disabled"]; + defaultMonth?: CalendarProps["defaultMonth"]; +}; + +const formatDateLabel = (date: Date) => { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +export function SearchDatePicker({ + selected, + onSelect, + placeholder = "날짜 선택", + className, + disabled, + defaultMonth, +}: SearchDatePickerProps) { + // 검색조건 달력은 날짜 선택과 동시에 Popover를 닫아야 하므로 open 상태를 화면 전용으로 제어한다. + const [open, setOpen] = useState(false); + + const handleSelect = (date: Date | undefined) => { + onSelect(date); + if (date) { + setOpen(false); + } + }; + + return ( + + + + + + + + + ); +} diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 3df6a1541..464e372d5 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' -import { Button, DatePicker, Input, Label } from '@common/components/ui' +import { Button, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlist } from '..' import { useQuery } from "@tanstack/react-query"; @@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import { SearchDatePicker } from '../_components/SearchDatePicker'; const Page = () => { @@ -176,20 +177,16 @@ const Page = () => {
- { if (date) setFromDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} + disabled={{ after: new Date() }} /> ~ - { if (date) setToDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} + disabled={{ after: new Date() }} />
diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index 86d5d4853..6fb88829c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -23,7 +23,7 @@ import { formatDate, handleDownload } from '@/components/lib/utils' import LoadingOverlay from '@/components/ui/LoadingBar' import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui' import ListDiv from '@common/business_components/ui/list_div' -import { Button, DatePicker, Input, Label } from '@common/components/ui' +import { Button, Input, Label } from '@common/components/ui' import { useEffect, useState } from 'react' import { tableHeadlistOldPp } from '..' import { useQuery } from "@tanstack/react-query"; @@ -31,6 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import { SearchDatePicker } from '../_components/SearchDatePicker'; const Page = () => { @@ -174,19 +175,15 @@ const Page = () => {
- { if (date) setFromDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} /> + disabled={{ after: new Date() }} /> ~ - { if (date) setToDate(date) }} - modifiers={{ - disabled: { after: new Date() } - }} /> + disabled={{ after: new Date() }} />
{ const { requireEnv = true, requireSido = true, requireSigungu = true, - requireTreatmentPlant = true + requireTreatmentPlant = true, + treatmentPlantLabel = '하수처리장' } = options; const validationConfigs: SelectionValidationConfig[] = [ { value: select1, - description: '하수처리장', + description: treatmentPlantLabel, required: requireTreatmentPlant } ]; @@ -133,6 +135,7 @@ export const validateBasicSelectionsWithAlert = ( requireSido?: boolean; requireSigungu?: boolean; requireTreatmentPlant?: boolean; + treatmentPlantLabel?: string; } = {} ): boolean => { const result = validateBasicSelections(select1, options); diff --git a/apps/sn/app/api/proxy/[...url]/route.ts b/apps/sn/app/api/proxy/[...url]/route.ts index cf6447b1a..999f54439 100644 --- a/apps/sn/app/api/proxy/[...url]/route.ts +++ b/apps/sn/app/api/proxy/[...url]/route.ts @@ -9,16 +9,18 @@ * - 에러 핸들링 및 상태 코드 반환 * - 사이트 ID 및 헤더 설정 */ +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + COOKIE_DOMAIN, + REFRESH_TOKEN, + SERVER_API_URL, + SITE_ID, +} from "@/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[] }, @@ -29,6 +31,11 @@ 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"); @@ -38,7 +45,10 @@ async function handler( headers[CLAIM_NAME] = refreshToken?.value ?? ""; if (!headers[CLAIM_NAME]) { - return NextResponse.json({ message: "Can't refresh" }, { status: 401 }); + return NextResponse.json( + { message: "Can't refresh" }, + { status: 401 }, + ); } } @@ -75,6 +85,7 @@ async function handler( cookieStore.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, sameSite: "lax", + domain: getCookieDomain(), path: "/", }); } @@ -109,3 +120,14 @@ export async function 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; +} \ No newline at end of file diff --git a/apps/sn/app/auth/login/page.tsx b/apps/sn/app/auth/login/page.tsx deleted file mode 100644 index 7a35b0ddd..000000000 --- a/apps/sn/app/auth/login/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoginPage } from "@/features/auth/login"; -export default LoginPage; diff --git a/apps/sn/app/auth/login/route-bak.ts b/apps/sn/app/auth/login/route-bak.ts deleted file mode 100644 index 796537cc3..000000000 --- a/apps/sn/app/auth/login/route-bak.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { tokenManager } from "@/entities/auth/lib/token-manager"; - -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") { - tokenManager.clearAuthToken(); - return NextResponse.redirect(new URL("/", request.url)); // 로컬 및 개발 환경 - } else { - return NextResponse.redirect(new URL(redirectUrl)); // 공단 환경 - } -} \ No newline at end of file diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 28c9f4500..ed59ba687 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -11,8 +11,10 @@ */ import { useEffect, useState } from "react"; import axios from 'axios'; +import { AxiosError } from 'axios'; import { CLAIM_NAME, SITE_ID } from "@/shared/config"; import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { getRemainingMs } from "@common/auth"; const BASE_URL = '/sewerage-service/api'; const DEFAULT_HEADERS = { @@ -29,10 +31,40 @@ const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ const resolveApiUrl = (url: string) => url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`; +const buildInvalidJsonResponseError = (url: string, text: string, contentType?: string | null) => { + const preview = text.trim().slice(0, 200); + const responseType = contentType ? ` (${contentType})` : ""; + return new Error( + `JSON 응답이 아닌 데이터를 받았습니다${responseType}: ${url}${preview ? ` - ${preview}` : ""}`, + ); +}; +const buildRequestError = ( + fallbackMessage: string, + status?: number, + body?: unknown, +) => { + const bodyMessage = + typeof body === "object" && body !== null + ? ( + (body as { message?: unknown }).message + ?? (body as { error?: { message?: unknown } }).error?.message + ) + : undefined; + const message = + typeof bodyMessage === "string" && bodyMessage.trim() + ? bodyMessage + : fallbackMessage; + const error = new Error(message) as Error & { status?: number; body?: unknown }; + error.status = status; + error.body = body; + return error; +}; + 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; @@ -79,7 +111,22 @@ export const getData = async (url: string): Promise => { throw new Error('Invalid JSON response from server'); } } - throw new Error('Failed to fetch data'); + + const text = await res.text().catch(() => ""); + let body: unknown = null; + if (text.trim()) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + + throw buildRequestError( + `Failed to fetch data: ${res.status} ${res.statusText}`, + res.status, + body, + ); }; export const postData = async (url: string, data: object): Promise => { @@ -101,6 +148,32 @@ export const postData = async (url: string, data: object): Promise => { } }; +export const postRawJson = async (url: string, data: object): Promise => { + const res = await fetch(resolveApiUrl(url), { + method: "POST", + headers: withSiteIdHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(data), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + const text = await res.text(); + const body = text && text.trim() ? JSON.parse(text) : null; + if (!res.ok) { + const message = + body?.message ?? + `Failed to post data: ${res.status} ${res.statusText}`; + const error = new Error(message) as Error & { status?: number; body?: any }; + error.status = res.status; + error.body = body; + throw error; + } + return body; +}; + const parseDownloadFileName = (contentDisposition: string | null) => { if (!contentDisposition) return undefined; @@ -142,29 +215,34 @@ export const postBlobData = async ( }; export const postMutiPart = async (url: string, data: FormData): Promise => { - const res = await axios.post(`${BASE_URL}/${url}`, data, { - headers: DEFAULT_HEADERS, - timeout: 600000, // 10분 - }); - console.log('postMultiPart response:', res); - return res.data.data; -}; + try { + const res = await axios.post(`${BASE_URL}/${url}`, data, { + headers: DEFAULT_HEADERS, + timeout: 600000, // 10분 + }); + console.log('postMultiPart response:', res); + return res.data.data; + } catch (error) { + if (error instanceof AxiosError) { + const responseBody = error.response?.data; + const serverMessage = + typeof responseBody?.message === 'string' && responseBody.message.trim() + ? responseBody.message.trim() + : typeof responseBody?.error?.message === 'string' && responseBody.error.message.trim() + ? responseBody.error.message.trim() + : typeof responseBody === 'string' && responseBody.trim() + ? responseBody.trim() + : null; -export const putData = async (url: string, data: object): Promise => { - const options: RequestInit = { - method: "PUT", - headers: withSiteIdHeaders({ - "Content-Type": "application/json", - }), - body: JSON.stringify(data), - }; - const res = await fetch(`${BASE_URL}/${url}`, options); - if (res.status === 401) { - handleUnauthorizedResponse(); + const nextError = new Error( + serverMessage ?? `Multipart request failed: ${error.response?.status ?? 'UNKNOWN'} ${error.response?.statusText ?? ''}`.trim(), + ) as Error & { status?: number; body?: unknown }; + nextError.status = error.response?.status; + nextError.body = responseBody; + throw nextError; + } + throw error; } - const resData = await res.json(); - console.log('putData response:', resData); - return resData.data; }; export const deleteData = async (url: string, data?: object): Promise => { @@ -211,11 +289,15 @@ export const getRawJson = async (url: string, init: RequestInit = {}): Promise void; setSwtrmtFcltNm?: (name: string) => void; + defaultSwtrmtFcltNm?: string; setIsLoading?: (isLoading: boolean) => void; } const defaultOption: ApiSelectOption = { val: '0', text: '전체' }; +const buildFacilityDefaultOption = (facilityName: string): ApiSelectOption => ({ + val: '0', + text: facilityName || defaultOption.text, +}); export function CtpvSggPrcsFcltSelectBox({ sido, @@ -52,6 +57,7 @@ export function CtpvSggPrcsFcltSelectBox({ selectedSwagePrcsFclt, onSwagePrcsFcltChange, setSwtrmtFcltNm, + defaultSwtrmtFcltNm, setIsLoading, }: CtpvSggPrcsFcltSelectBoxProps) { @@ -59,18 +65,7 @@ export function CtpvSggPrcsFcltSelectBox({ const { ctpv, sgg } = useStore(); const sidoStr = String(sido ?? ''); const sidoPrefix = sidoStr.slice(0, 2); - - useEffect(() => { - (async () => { - onSidoChange("0"); - onSigunguChange("0"); - if (setCtpvName && setSggName) { - setCtpvName(""); - setSggName(""); - } - if (setSwtrmtFcltNm) setSwtrmtFcltNm(""); - })(); - }, []); + const normalizedDefaultFacilityName = String(defaultSwtrmtFcltNm ?? '').trim(); useEffect(() => { onSwagePrcsFcltChange?.('0'); @@ -115,6 +110,25 @@ export function CtpvSggPrcsFcltSelectBox({ fetchData(); }, [sggName]); + useEffect(() => { + if (!normalizedDefaultFacilityName) return; + if (!selectedSwagePrcsFclt || selectedSwagePrcsFclt !== "0") return; + if (prcsFcltOptions.length === 0) return; + + const matchedDefaultFacility = prcsFcltOptions.find((option) => + String(option.text ?? "").trim() === normalizedDefaultFacilityName, + ); + if (!matchedDefaultFacility) return; + + onSwagePrcsFcltChange?.(String(matchedDefaultFacility.val)); + setSwtrmtFcltNm?.(matchedDefaultFacility.text); + }, [ + normalizedDefaultFacilityName, + onSwagePrcsFcltChange, + prcsFcltOptions, + selectedSwagePrcsFclt, + setSwtrmtFcltNm, + ]); return (
@@ -153,7 +167,7 @@ export function CtpvSggPrcsFcltSelectBox({ className="w-[160px]" selectClass="rounded-lg bg-white truncate" label="" - selectArray={[defaultOption, ...prcsFcltOptions]} + selectArray={[buildFacilityDefaultOption(normalizedDefaultFacilityName), ...prcsFcltOptions]} value={selectedSwagePrcsFclt} onSelectValue={(val) => { onSwagePrcsFcltChange?.(String(val)) @@ -165,4 +179,4 @@ export function CtpvSggPrcsFcltSelectBox({ )}
); -} \ No newline at end of file +} diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx index 1ed617243..a03641e1e 100644 --- a/apps/sn/app/components/ui/verticalTable.tsx +++ b/apps/sn/app/components/ui/verticalTable.tsx @@ -45,17 +45,37 @@ type UiTableProps = { onFormDataChange?: (formData: { [key: string]: string | number | boolean }) => void; // 추가 }; +type UiSelectOption = + | string + | { + value?: string | number; + label?: string; + val?: string | number; + text?: string; + hidden?: boolean; + }; + type UiHeadProps = { id: string; title: string; align?: string; type?: string; - options?: string[]; + options?: UiSelectOption[]; placeholder?: string; isNumber?: boolean; length?: number; + required?: boolean; }; +const getSelectOptionValue = (option: UiSelectOption) => + typeof option === "string" ? option : String(option.value ?? option.val ?? ""); + +const getSelectOptionLabel = (option: UiSelectOption) => + typeof option === "string" ? option : String(option.label ?? option.text ?? getSelectOptionValue(option)); + +const isSelectOptionHidden = (option: UiSelectOption) => + typeof option === "string" ? false : Boolean(option.hidden); + export function VerticalTable({ headName, tableName1, @@ -153,6 +173,7 @@ export function VerticalTable({ {/* 컬럼 제목 */} {head.title} + {head.required ? * : null} {/* 데이터 값 또는 입력 필드 */} @@ -160,21 +181,28 @@ export function VerticalTable({ head.type === "select" ? ( - ) : head.type === "input" ? ( + ) : head.type === "input" || head.type === "date" ? (
{ if (head.isNumber) { @@ -241,8 +269,9 @@ export function VerticalTable({ {/* 컬럼 제목 */} - {head.title} - + {head.title} + {head.required ? * : null} + {/* 데이터 값 또는 입력 필드 */} {children && children[head.id] ? children[head.id] : isEditable2 ? ( @@ -253,17 +282,24 @@ export function VerticalTable({ onChange={(e) => handleInputChange(head.id, e.target.value)} className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0" > - {head.options?.map((option) => ( - - ))} + {head.options?.map((option, optionIndex) => { + const optionValue = getSelectOptionValue(option); + return ( + + ); + })} - ) : head.type === "input" ? ( + ) : head.type === "input" || head.type === "date" ? (
{ if (head.isNumber) { @@ -333,4 +369,4 @@ export function VerticalTable({ ); -} \ No newline at end of file +} diff --git a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx index 549273158..d0be9a0ce 100644 --- a/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx +++ b/apps/sn/app/html/(map-layout)/aged-pipe/page.tsx @@ -31,6 +31,22 @@ const agedPipeHighlightStyle = new Style({ const PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; const EmptySearchView = () => null; +const normalizeTreeRowSpacing = (data: any[]) => { + if (!Array.isArray(data)) return data; + + return data.map((item) => { + if (item?.lyrNm !== "지반침하정보") { + return item; + } + + return { + ...item, + iconNm: null, + gobjTy: "", + }; + }); +}; + const DESIGN_PREVIEW_FEATURE = { fid: "preview-aged-pipe", lyrTypeNm: PIPE_LAYER_TYPE, @@ -103,7 +119,7 @@ const Map = () => { const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); - setTreeData(data); + setTreeData(normalizeTreeRowSpacing(data)); } catch (error) { console.error("노후관로 레이어 설정 조회 중 오류:", error); throw new Error("Invalid JSON response from server"); diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx index b02c4bdca..a81b401db 100644 --- a/apps/sn/app/page.tsx +++ b/apps/sn/app/page.tsx @@ -15,7 +15,7 @@ * - 로그인: POST /api/v1/auth/login (user-service) */ "use client"; -import { LoginApi } from "@/entities/auth"; +import { authService } from "@/entities/auth"; import { useEffect } from "react"; const DEV_HOSTNAMES = new Set(["localhost", "127.0.0.1", "con.mind-one.co.kr"]); @@ -38,7 +38,7 @@ export default function Home() { async function doLogin() { try { // 개발 환경에서는 남아 있는 만료/부분 쿠키로 인한 /map <-> / 루프를 막기 위해 항상 토큰을 재발급한다. - await LoginApi.login(DEV_LOGIN_PAYLOAD); + await authService.login(DEV_LOGIN_PAYLOAD); window.location.replace("/map"); } catch (err) { console.error("로그인 실패", err); diff --git a/apps/sn/docs/기준문서/005-상태관리정책.md b/apps/sn/docs/기준문서/005-상태관리정책.md index 8533b442c..e4cce1ef4 100644 --- a/apps/sn/docs/기준문서/005-상태관리정책.md +++ b/apps/sn/docs/기준문서/005-상태관리정책.md @@ -8,6 +8,20 @@ - 확인됨: Zustand에서 서버 기준 데이터를 조회해 보관하는 경우에는 로딩, 오류, 중복 호출 방지, 재조회 조건을 함께 정의한다. - 확인됨: 같은 의미의 기준 데이터 store를 두 계열 이상 유지하지 않는다. 기존 store가 겹치면 조회 출처, 데이터 shape, 필터링 규칙을 비교하고 하나의 기준으로 정리한다. - 확인됨: React Query를 사용하는 Provider는 QueryClient 인스턴스가 렌더마다 새로 생성되지 않도록 안정적으로 유지한다. +- 확인됨: 지도형 편집 기능은 조회 상태, 상호작용 상태, 편집 세션 상태, 저장 상태를 한 덩어리로 섞지 않고 분리해 기록한다. 근거: `015-하네스엔지니어링정책.md` + +## 지도 상호작용 상태 기록 항목 +- 확인 필요: `isEditMode`처럼 편집 모드 on/off 상태가 분리되어 있는가 +- 확인 필요: `selectedLayer`, `activeTool`, `selectedFeatureId`처럼 현재 조작 대상을 식별하는 상태가 있는가 +- 확인 필요: `pendingInteraction`, `focusedChangeId`처럼 도구 전환과 지도 interaction 정리를 위한 중간 상태가 있는가 +- 확인 필요: `changes[]`, `canUndo`, `canSave`처럼 편집 세션 누적 상태가 별도로 드러나는가 +- 확인 필요: `savePending`, `saveError`, `statusMessage`처럼 저장 상태와 사용자 피드백이 분리되어 있는가 +- 확인 필요: preview feature, vertex handle, 임시 삭제 스타일 같은 지도 시각 표식이 어떤 상태와 연결되는지 문서에서 추적 가능한가 + +## 지도 상호작용 상태 전이 기록 기준 +- 확인됨: 도구 전환 시 이전 interaction 정리 조건을 함께 적는다. +- 확인됨: 선택 없음, 레이어 미선택, 객체 미선택, 저장 가능, 저장 불가 같은 경계 상태를 표나 목록으로 남긴다. +- 확인됨: canvas 조작 결과를 직접 읽기 어려우면 상태 메시지, 변경 목록, preview label 같은 간접 표식을 함께 둔다. ## 화면 흐름 기록 항목 - 확인 필요: 초기 진입 시 호출되는 API @@ -19,6 +33,7 @@ - 확인 필요: 로딩, 오류, 중복 호출 방지, 재조회 조건 - 확인 필요: 팝업, 모달, 지도, 파일, 엑셀 흐름 - 확인 필요: 에러 메시지 표시 위치 +- 확인 필요: 지도 상호작용 관측 포인트와 자동검증 가능한 화면 표식 ## 주의 사항 - 확인됨: 요구사항 근거 없는 기능 추가는 금지한다. 근거: `apps/sn/AGENTS.md` diff --git a/apps/sn/docs/기준문서/006-검증정책.md b/apps/sn/docs/기준문서/006-검증정책.md index 833d33a4a..a8aef0a96 100644 --- a/apps/sn/docs/기준문서/006-검증정책.md +++ b/apps/sn/docs/기준문서/006-검증정책.md @@ -1,5 +1,10 @@ # 검증 기준 +## 공통 원칙 +- 확인됨: 하네스 친화 검증의 공통 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 우선 참조한다. +- 확인됨: 기능별 자동검증 선행조건, 관측 포인트, 자동/수동 검증 경계는 각 기능의 작업카드와 통합검증계획에 남긴다. +- 확인됨: 인증 차단, 환경 차단, 데이터 차단, 자동화 한계는 기능 결함과 분리해 기록한다. + ## 기본 빌드 검증 - 확인됨: `sn`만 수정한 경우 아래 명령으로 로컬 빌드를 확인한다. 근거: `apps/sn/AGENTS.md` @@ -28,7 +33,10 @@ pnpm turbo run build --filter="...[origin/main]" ## 브라우저 검증 - 확인됨: 백엔드 API 응답과 실제 브라우저 화면 동작을 함께 확인하는 것을 기본으로 한다. 근거: `apps/sn/AGENTS.md` - 확인 필요: 각 기능별 URL, 로그인 계정, 권한, 테스트 데이터는 작업카드에서 확정한다. 근거: 실행 환경 확인 필요 +- 확인됨: 버튼 상태, 상태 메시지, 변경 목록, 네트워크 payload처럼 자동으로 관측 가능한 항목을 먼저 고정한다. 근거: `015-하네스엔지니어링정책.md` +- 확인됨: 지도 canvas drag, vertex 이동처럼 자동화가 불안정한 항목은 수동 검증으로 전환할 수 있으나, 전환 사유를 남겨야 한다. 근거: `015-하네스엔지니어링정책.md` ## 검증 기록 -- 확인됨: 검증 결과는 `보고/YYYY-MM-DD/결과보고.md`에 기록한다. 근거: 이 하네스 운영 문서 -- 확인됨: 외부 범위 오류는 `보고/YYYY-MM-DD/이슈.md`에 기록한다. 근거: 이 하네스 운영 문서 \ No newline at end of file +- 확인됨: 기능 검증 계획은 기능별 `통합검증계획` 문서에 누적한다. 근거: `014-문서관리정책.md` +- 확인됨: 기능 검증 결과는 기능별 `통합검증결과` 문서에 누적한다. 근거: `014-문서관리정책.md` +- 확인됨: 외부 범위 오류나 공통 차단 이슈는 기능 검증결과에 요약하고, 여러 채팅방에 영향을 주는 경우 공통 결정로그 또는 공통 작업내용 문서에 함께 남긴다. 근거: `015-하네스엔지니어링정책.md` diff --git a/apps/sn/docs/기준문서/008-설계정책.md b/apps/sn/docs/기준문서/008-설계정책.md index 62e300a72..f5a3e8753 100644 --- a/apps/sn/docs/기준문서/008-설계정책.md +++ b/apps/sn/docs/기준문서/008-설계정책.md @@ -15,11 +15,25 @@ - 폼과 조회 조건은 화면 컴포넌트 내부에서 뒤섞지 말고 역할 단위로 분리한다. - 지도, 표, 팝업이 함께 있는 화면은 사용자 흐름 기준으로 이벤트 책임을 나눈다. +## 3-1. 지도 상호작용 설계 원칙 +- 편집 도구별 책임은 `선택`, `추가`, `이동`, `꼭지점편집`, `삭제`, `저장`처럼 사용자 행위 단위로 구분한다. +- 같은 지도 feature를 재사용하더라도 `MOVE`와 `VERTEX_EDIT`처럼 interaction lifecycle이 다른 도구는 상태 전이와 정리 조건을 분리한다. +- 기존 상세 팝업, 레이어 선택, 지도 클릭 이벤트와 편집 도구가 충돌하면 우선순위 규칙을 설계 문서에 남긴다. +- 지도 상호작용 결과는 canvas 안에서만 끝나지 않게 상태 메시지, 변경 목록, preview label, 버튼 활성 상태 중 하나 이상으로 바깥에서 관측 가능해야 한다. +- `map/page.tsx` 같은 대형 화면 파일에 책임이 몰려 있으면, 코드 리팩터링 전에도 문서에서 상태 경계와 컴포넌트 분리 목표를 먼저 고정한다. + ## 4. API 연동 원칙 - 프론트는 실제 백엔드 DTO, API 명세, 에러 응답을 기준으로 맞춘다. - 필드명 해석이 애매하면 프론트에서 임의 변환 규칙을 늘리기보다 계약을 먼저 확인한다. - 목록/상세/다운로드 API는 요청 파라미터와 응답 구조를 문서에 남긴다. +## 4-1. 하네스 친화 설계 원칙 +- 자동검증 선행조건, 테스트 데이터, 인증 요구사항은 설계 또는 작업카드에서 명시한다. +- 브라우저 자동화로 안정적으로 확인할 수 있는 관측 포인트를 설계 단계에서 먼저 정한다. +- 자동화가 어려운 canvas drag, vertex 이동은 수동 검증으로 전환할 수 있지만, 전환 사유와 대체 관측 포인트를 함께 둔다. +- 인증 차단, 환경 차단, 데이터 차단, 자동화 한계는 기능 결함과 분리해 설계와 검증 문서에 반영한다. + ## 5. 공통 문서 참조 - 공통 용어는 `COMMON_DOCS_ROOT/기준문서/003-공통용어사전.md`를 우선 참조한다. - 공통 환경/빌드 이슈는 `COMMON_DOCS_ROOT/기준문서/004-환경빌드문제대응.md`를 함께 본다. +- 하네스 친화 검증 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 함께 참조한다. diff --git a/apps/sn/docs/기준문서/014-문서관리정책.md b/apps/sn/docs/기준문서/014-문서관리정책.md new file mode 100644 index 000000000..17678e18f --- /dev/null +++ b/apps/sn/docs/기준문서/014-문서관리정책.md @@ -0,0 +1,155 @@ +# 014. 문서관리정책 + +## 1. 목적 + +- `sn` 앱과 연동 기능 문서를 기능 단위 통합 문서 방식으로 관리한다. +- 라운드별 개별 md가 계속 늘어나는 문제를 막고, 나중에 한 기능의 전체 맥락을 빠르게 읽을 수 있게 한다. + +## 2. 기본 원칙 + +- 기능마다 아래 문서만 유지한다. + - 설계 문서 1개 + - 작업내용 문서 1개 + - 작업검증 계획 문서 1개 + - 작업검증 결과 문서 1개 +- 세부 이력은 새 파일을 만들지 않고 기존 문서 내부 헤더로 누적한다. +- 라운드별 `002`, `003`, `004` 식 후속 파일 추가를 기본 금지한다. +- 예외적으로 별도 문서가 꼭 필요하면, 왜 기존 통합 문서 헤더로 해결되지 않는지 먼저 기록한다. +- 하네스 정보도 별도 기능 md를 늘리기보다 기존 4문서에 배치한다. +- 하네스 정보 배치 기준은 `FE_APP_DOCS_ROOT/기준문서/015-하네스엔지니어링정책.md`를 따른다. + +## 3. 경로별 생성 규칙 + +### 3.1 공통 설계 문서 + +- 경로 + - `COMMON_DOCS_ROOT/작업계획/<기능폴더>/` +- 파일 + - `001-de-<기능명>-설계.md` +- 규칙 + - 설계는 기능당 1개만 유지한다. + - 후속 설계 변경, 상태 재검토, QGIS 반영, API 계약 보완도 기존 설계 문서에 새 헤더로 누적한다. + +### 3.2 `sn` 작업내용 문서 + +- 경로 + - `FE_APP_DOCS_ROOT/작업내용/<기능폴더>/` +- 파일 + - `001-fe-<기능명>-통합구현기록.md` +- 규칙 + - 구현 이력, 라운드별 변경, 결정사항, 리스크, BE 공유사항을 한 문서에 누적한다. + - 기존 `001`, `002`, `003` 개별 구현 문서는 통합 문서에 흡수 후 정리한다. + +### 3.3 `sn` 작업검증 문서 + +- 경로 + - `FE_APP_DOCS_ROOT/작업검증/<기능폴더>/` +- 파일 + - `001-qa-<기능명>-통합검증계획.md` + - `002-qa-<기능명>-통합검증결과.md` +- 규칙 + - 검증 계획은 `통합검증계획` 1개에만 누적한다. + - 검증 결과는 `통합검증결과` 1개에만 누적한다. + - 회귀 검증, 후속 검증, 도구별 검증도 새 파일이 아니라 각 문서 내부 헤더로 구분한다. + +## 4. 문서 내부 헤더 규칙 + +### 4.1 설계 문서 + +- 권장 섹션 + - 목적 + - 입력 근거 + - 현재 확인 상태 + - 기능 범위 + - 사용자 흐름 + - 화면/UI 계약 + - API 계약 + - 검증 기준 + - 남은 리스크 +- 하네스 정보 배치 + - 도구 상태 전이표 + - 지도 상호작용 관측 포인트 + - 자동검증 가능 범위와 수동 검증 경계 +- 후속 반영은 아래 형식 중 하나로 누적한다. + - `## 2-1. 외부 참조 기준` + - `### 6.6 도구별 세부 계약` + - `### 6.8 지도 상호작용 관측 포인트` + - `### 7.5 도구 상태 전이표` + - `## YYYY-MM-DD 설계 변경 메모` + +### 4.2 통합 구현 기록 + +- 권장 섹션 + - 문서 목적 + - 입력 근거 + - 공통 계약 + - 구현 라운드 요약 + - 기능별 현재 상태 + - BE 공유 사항 + - 남은 리스크 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 자동화 차단 요인 + - 관측 가능성 확보를 위해 추가한 상태/메시지/표식 + - 하네스 친화화 부채 +- 라운드별 구현은 `### 4.x` 식으로 누적한다. + +### 4.3 통합 검증 계획 + +- 권장 섹션 + - 문서 목적 + - 검증 범위 + - 검증 기준 + - 라운드별 검증 계획 + - 다음 검증 순서 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 브라우저 검증 선행조건 + - 관측 포인트 표 + - 자동검증 항목과 수동검증 항목 구분 + - 차단 사유 분류 기준 + +### 4.4 통합 검증 결과 + +- 권장 섹션 + - 문서 목적 + - 수행한 검증 + - 라운드별 검증 결과 + - 현재 검증 상태 + - 보류/리스크 + - 흡수한 기존 문서 +- 하네스 정보 배치 + - 실제 관측 증적 요약 + - 차단 사유 분류 + - 자동화 실패와 기능 실패 구분 결과 + +## 5. 기존 개별 문서 흡수 규칙 + +- 기존 개별 문서를 유지할 필요가 없으면 다음 순서로 정리한다. + 1. 통합 문서에 핵심 내용을 요약/흡수 + 2. 통합 문서 `흡수한 기존 문서` 섹션에 파일명을 기록 + 3. 기존 개별 문서를 정리한다 +- 흡수 시 원래 문서의 모든 문장을 그대로 복사하기보다, 결정사항과 검증 결과를 구조화해서 재작성한다. + +## 6. 새 기능 시작 시 문서 생성 순서 + +1. 공통 설계 문서 1개 생성 +2. `sn` 작업카드 1개 생성 +3. `sn` 통합 구현 기록 1개 생성 +4. `sn` 통합 검증 계획 1개 생성 +5. `sn` 통합 검증 결과 1개 생성 +6. 이후부터는 기존 5개 문서만 계속 갱신 + +## 7. 금지 사항 + +- 구현 라운드마다 `002-fe-...`, `003-fe-...`, `004-qa-...`를 계속 추가하지 않는다. +- 같은 기능에서 `후속검증계획.md`, `후속검증결과.md`, `MOVE실드래그검증계획.md`, `VERTEX_EDIT실조작검증결과.md` 같은 파생 검증 파일을 반복 증식시키지 않는다. +- `상태재검토`, `후속정리`, `보완메모`를 별도 파일로 분리하지 않는다. + +## 8. 예외 처리 + +- 사용자 요청으로 개별 문서 분리가 명시된 경우 +- 외부 전달용 산출물이라 통합 문서 내부 헤더로 대체할 수 없는 경우 +- 법적/감사/계약상 독립 파일 보관이 필요한 경우 + +예외 상황에서도 먼저 통합 문서에 “왜 별도 파일이 필요한지”를 기록한다. diff --git a/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md b/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md new file mode 100644 index 000000000..2499f94ff --- /dev/null +++ b/apps/sn/docs/기준문서/015-하네스엔지니어링정책.md @@ -0,0 +1,126 @@ +# 015. 하네스엔지니어링정책 + +## 1. 목적 + +- `sn` 앱 기능을 브라우저 자동검증, 수동 회귀 검증, FE-BE 연동 관찰이 가능한 구조로 문서화하는 기준을 통일한다. +- 구현 코드 구조를 바로 바꾸기 전에도, 문서 단계에서 관측 포인트와 차단 요인을 먼저 드러내어 하네스 친화성을 높인다. +- 기능별 문서에 하네스 정보가 중복되거나 누락되지 않도록 공통 배치 기준을 제공한다. + +## 2. 적용 범위 + +- `FE_APP_DOCS_ROOT/기준문서/**` +- `FE_APP_DOCS_ROOT/작업계획/**` +- `FE_APP_DOCS_ROOT/작업내용/**` +- `FE_APP_DOCS_ROOT/작업검증/**` +- `COMMON_DOCS_ROOT/작업계획/**` 중 `sn` 화면/상호작용 계약을 담는 설계 문서 + +## 3. 기본 원칙 + +- 하네스 정보는 기능별로 새 md를 늘리기보다 기존 4문서 체계 안에 배치한다. +- 자동검증 가능 여부와 무관하게, 사용자가 실제로 무엇을 보고 성공/실패를 판단하는지 먼저 문서화한다. +- 브라우저 자동화 한계, 인증 차단, 외부 시스템 장애는 기능 결함과 분리해 기록한다. +- 지도 상호작용 기능은 도구 상태, 선택 상태, 세션 상태, 네트워크 상태를 따로 관측할 수 있어야 한다. +- 코드 구조가 과밀해도 문서에서는 책임 경계, 상태 전이, 관측 포인트를 먼저 고정한다. + +## 4. 자동검증 선행조건 + +- 접속 URL +- 로그인 경로와 권한 +- 필수 쿠키 또는 토큰 존재 여부 +- 서버 기동 상태와 대상 API base URL +- 테스트용 레이어, 객체 ID, BBOX, 샘플 데이터 +- 브라우저 자동화 가능 범위와 수동 검증 전환 조건 + +인증 또는 외부 연동 문제로 위 조건이 충족되지 않으면, 이를 기능 실패로 단정하지 말고 `환경 차단`, `인증 차단`, `데이터 차단`, `자동화 한계` 중 하나로 분류한다. + +## 5. 관측 포인트 표준 + +### 5.1 화면 관측 + +- 버튼 활성/비활성 +- 툴바 노출/숨김 +- 상태 메시지 텍스트 +- 선택 레이어명 +- 변경 목록 행 수 +- 오류 또는 안내 메시지 + +### 5.2 지도 상호작용 관측 + +- 현재 활성 도구 +- 선택 객체 존재 여부 +- preview feature 존재 여부 +- vertex marker 또는 modify handle 노출 여부 +- drag 또는 modify 종료 후 geometry 변경 여부 +- 삭제 대상 임시 스타일 적용 여부 + +### 5.3 네트워크 관측 + +- 요청 URL, method, query/body +- 응답 status +- 편집 세션 payload shape +- 인증 실패 또는 권한 실패 응답 + +### 5.4 세션 관측 + +- 로컬 변경분 누적 여부 +- undo/reset 가능 여부 +- save 가능 여부 +- save pending/save error 상태 + +## 6. 자동검증과 수동검증 경계 + +- DOM, 텍스트, 버튼 상태, 네트워크 호출, payload 구조는 자동검증 우선 대상으로 본다. +- canvas 기반 drag, vertex 이동, 지도 좌표 조작은 자동화가 불안정하면 수동 검증으로 전환할 수 있다. +- 수동 전환 시에는 이유를 `자동화 한계`로 명시하고, 어떤 관측 포인트를 사람 손으로 확인했는지 남긴다. + +## 7. 인증·세션 기준 + +- 인증 장애는 기능 결함과 분리해 기록한다. +- 로컬 브라우저 검증이 인증 때문에 막히면 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 우선 참조한다. +- 기능 문서에는 해당 기능이 요구하는 최소 권한, 로그인 여부, 세션 유지 시간, 재로그인 필요 조건만 요약한다. + +## 8. 기능 문서 배치 규칙 + +### 8.1 설계 문서 + +- 도구 상태 전이표 +- 지도 상호작용 관측 포인트 +- 자동검증 가능 범위와 수동 검증 경계 + +### 8.2 작업카드 + +- 브라우저 검증 선행조건 +- 테스트 데이터와 샘플 진입 조건 +- 인증 차단 시 대체 경로 + +### 8.3 통합 구현 기록 + +- 현재 자동화 차단 요인 +- 하네스 친화화 부채 +- 관측 가능성 확보를 위해 추가된 상태/메시지/표식 + +### 8.4 통합 검증 계획 + +- 관측 포인트 표 +- 자동검증 항목과 수동검증 항목 구분 +- 차단 사유 분류 기준 + +### 8.5 통합 검증 결과 + +- 실제 관측 증적 요약 +- 차단 사유 분류 +- 자동화 실패와 기능 실패의 구분 결과 + +## 9. 차단 사유 분류 + +- `기능 결함`: 화면 동작, 상태 전이, payload, 응답 처리 자체가 기대와 다름 +- `인증 차단`: 로그인, 토큰, 권한 부족으로 기능 진입 불가 +- `환경 차단`: 서버 미기동, 잘못된 env, CORS, 프록시, 네트워크 장애 +- `데이터 차단`: 테스트 데이터, 레이어 메타, 객체 샘플 부족 +- `자동화 한계`: 브라우저 자동화 도구로 canvas drag, vertex 조작이 안정적으로 재현되지 않음 + +## 10. 금지 사항 + +- 같은 주제라고 해서 `브라우저자동검증기준.md`, `인증검증기준.md`, `상태머신.md`를 기능마다 별도 생성하지 않는다. +- 관측 포인트 없이 `수동 확인 필요`만 적고 끝내지 않는다. +- 인증 실패, 환경 실패, 자동화 한계를 모두 기능 결함으로 뭉뚱그려 기록하지 않는다. diff --git a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/001-qa-SFR-010-010-지반침하정보-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/001-qa-SFR-010-010-지반침하정보-통합검증계획.md new file mode 100644 index 000000000..c40a4b210 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/001-qa-SFR-010-010-지반침하정보-통합검증계획.md @@ -0,0 +1,142 @@ +# SFR-010-010 지반침하정보 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-010-010 지반침하정보`의 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 이번 턴은 설계 단계이므로 구현 전 검증 시나리오와 차단 조건을 먼저 기록한다. +- 승인 후 구현이 시작되면 이 문서에 라운드별 검증 계획을 계속 추가한다. + +## 2. 검증 기준 요약 + +- 레이어 트리에서 `지반침하정보` 체크/해제가 동작해야 한다. +- 목록 아이콘 클릭 시 지반침하 목록 팝업이 열려야 한다. +- 목록 조회조건 검증이 동작해야 한다. + - 날짜 누락 차단 + - `2016-01-01` 이전 날짜 차단 +- 목록 행 클릭 또는 지도 포인트 클릭 시 우측 상세 패널이 열려야 한다. +- 반경 적용 시 지도 원과 반경 내 관로 목록이 함께 갱신돼야 한다. +- 좌표 없음/좌표 변환 실패/반경 내 관로 없음 케이스가 서로 다른 안내로 분기돼야 한다. +- 관리자만 동기화 버튼과 관련 운영 정보를 볼 수 있어야 한다. + +## 3. 라운드별 검증 계획 + +### 3.1 설계 단계 사전 계획 + +- FE 화면 구조 검증 + - 레이어 트리 행 구성 + - 반경 패널 위치 + - 목록 팝업 열림 방식 + - 우측 상세 패널 레이아웃 +- BE 계약 검증 + - geometry/list/detail/nearby/sync/download endpoint 일치 여부 + - 원천 부재 필드 nullable 처리 여부 + - 관리자 권한 분리 여부 +- DB 영향 검증 + - IL 원천 조회 + - 반경 내 관로 조회 대상 + - 좌표 캐시 영속화 필요 여부 + +### 3.2 구현 후 예정 검증 + +- 지반침하 레이어 체크/해제 시 지도 포인트 표출 여부 +- 목록 팝업 조회 / 결과 없음 / 다운로드 검증 +- 목록 행 클릭 후 지도 중심 이동과 상세 패널 오픈 +- 지도 포인트 클릭 후 상세 패널 오픈 +- 반경 값 변경 후 재조회 +- 좌표 없는 사고건 상세 표시 +- 관리자 동기화 성공/실패 검증 + +### 3.3 2026-06-26 레이어 연도범위 설정 전환 검증 계획 + +- 레이어 트리 검증 + - `지반침하정보` 루트만 표시되고 `2026년`, `전체` 자식 노드가 표시되지 않는지 확인한다. + - 루트 체크/해제만으로 지도 포인트 레이어가 on/off 되는지 확인한다. +- 선택 레이어 설정 검증 + - `지반침하정보` 체크 시 하단 `선택 레이어 설정`에 반경 입력과 함께 `시작년도`, `종료년도`가 표시되는지 확인한다. + - 현재 연도 2026 기준 최초값이 `2025`, `2026`인지 확인한다. + - 적용 시 `/api/v1/sinkholes` 요청에 `startYmd=20250101`, `endYmd=20261231`이 포함되는지 확인한다. + - 시작년도가 종료년도보다 큰 경우, 2016년 이전 입력, 4자리 미만 입력을 차단하는지 확인한다. +- 기존 기능 회귀 검증 + - 반경 입력/적용, 우측 상세 패널, 반경 내 관로 조회가 기존처럼 동작하는지 확인한다. + - 목록 팝업 오픈 시 설정패널 연도 범위가 초기 조회조건으로 전달되는지 확인한다. + - 팝업 조회 후 지도 동기화와 팝업 닫힘 후 복귀 기준이 문서화된 정책과 일치하는지 확인한다. +- DB 기준정보 검증 + - `tb_sn_lyr_stng`에서 `지반침하정보` 루트는 유지되고 `2026년`, `전체` 자식 행이 삭제 또는 비활성화됐는지 확인한다. + +## 4. 브라우저 검증 선행조건 + +- `/map` 진입 가능한 로그인 상태 +- FE dev 서버와 BE API 동시 기동 +- IL 원천 데이터와 관로 데이터 접근 가능 +- 관리자/일반 사용자 계정 각각 1개 이상 + +## 5. 차단 사유 분류 기준 + +- `기능 결함` + - 화면 상태 전이 또는 API 응답 처리 오류 +- `인증 차단` + - 로그인/권한 부족 +- `환경 차단` + - 서버 미기동, 프록시, 네트워크 문제 +- `데이터 차단` + - 원천 데이터 또는 샘플 사고건 부족 +- `계약 미확정` + - 원천 부재 필드 처리, 캐시 정책, 팝업 방식 미확정 + +## 6. 현재 보류 메모 + +- 이번 턴은 설계 단계이므로 실제 브라우저/API 실행 검증은 수행하지 않는다. +- 구현 승인 전에는 이 문서의 시나리오와 설계 문서의 계약 일치 여부만 점검 대상으로 둔다. + +### 3.4 2026-06-26 레이어 연도범위 설정 전환 구현 검증 계획 + +- 지반침하정보 레이어 트리에 `2026년`, `전체` 자식 노드가 표시되지 않고 루트 레이어만 on/off 되는지 확인한다. +- 선택 레이어 설정 패널에 시작년도/종료년도 입력이 표시되고 기본값이 현재년도 기준 최근 1년인지 확인한다. +- 연도 범위 적용 후 지도 조회 요청의 `startYmd/endYmd`가 시작년도 `0101`, 종료년도 `1231`로 변환되는지 확인한다. +- 목록 팝업을 열었을 때 현재 지도 연도 범위가 초기 조회조건으로 전달되는지 확인한다. +- 잘못된 연도 입력(4자리 미만, 시작년도 > 종료년도, 2016년 이전, 현재년도 초과)이 차단되는지 확인한다. +### 3.5 2026-07-13 우측 상세 탭 유지보수 검증 계획 + +- 지반침하 A/B를 열었을 때 사고번호별 탭 2개가 유지되는지 확인한다. +- 지반침하에서 관로 1/2를 연속 선택해도 관로 탭은 1개이며 마지막 관리번호와 상세로 교체되는지 확인한다. +- 관로 표시명 옆의 소형 `←`가 직전 부모 지반침하로 이동하고 지도 관로 포커스와 반경 표시가 함께 복원되는지 확인한다. +- 지도에서 직접 선택한 관로에는 `←`가 표시되지 않는지 확인한다. +- 처리시설/처리구역 탭에 실제 시설명/구역명과 유형별 색상이 표시되는지 확인한다. +- 부모 지반침하를 닫으면 관로의 부모 링크가 사라지고, 최대 5개 자동 정리에서는 활성 관로의 부모가 보호되는지 확인한다. +- 자동 검증은 `diff --check`, TypeScript, Next build로 수행하고, 화면 상호작용은 로그인 가능한 `/map` 통합 환경에서 수동 수행한다. + +### 3.6 2026-07-14 지도 UX 현행 유지보수 검증 계획 + +- 반경이 다른 사고를 각각 선택해 지도 라벨 `반경 {값}m`와 상세 패널 값이 일치하는지 확인한다. +- 새 사고 선택, 탭 닫기, 빈 지도 클릭, 레이어 해제 시 반경 원·라벨·중심점이 함께 제거되는지 확인한다. +- 지반침하 루트에 기존 파란 심볼이 보이고 다른 앱/서비스의 공통 트리 노출은 바뀌지 않는지 확인한다. +- 빈 지도 클릭은 현재 선택 지반침하와 종속 관로 강조만 해제하고 다른 지반침하·처리시설·처리구역·GIS 편집 탭은 유지하는지 확인한다. +- 다른 피처 클릭과 GIS 편집 핸들/중간점/미리보기 클릭이 빈 영역 해제로 오인되지 않는지 확인한다. +- 비활성 지반침하를 정리할 때 현재 활성 탭이 바뀌지 않는지 확인한다. +- 지반침하발생현황 그리드 첫 열에 사고번호가 표시되고 화면 NO 열은 없으며 행 선택·페이징·정렬이 회귀하지 않는지 확인한다. +- 정적 검사는 변경 파일 구문/타입, `git diff --check`, Next build로 수행하고 상호작용은 로그인 가능한 `/map`에서 수동 확인한다. +- BE는 기존 목록·상세·반경 응답의 `acdntNo`, `radiusMeters` 유지 여부만 회귀 확인한다. + +### 3.7 2026-07-14 빈 지도 클릭 선택 해제 수동 검증 절차 + +1. 로그인 후 `/map`에서 지반침하정보 레이어를 켠다. +2. 목록이 아니라 지도 위 지반침하 포인트를 직접 선택하고 우측 상세 탭, 반경 원, `반경 {값}m` 라벨이 표시되는지 확인한다. +3. 다른 피처·지도 컨트롤·GIS 편집 핸들이 없는 빈 지도 영역을 클릭한다. +4. 선택한 지반침하 상세 탭 또는 현재 선택 상태, 반경 원·라벨·중심점, 종속 관로 강조가 함께 해제되는지 확인한다. 기본 지반침하 레이어의 포인트는 남아 있어야 한다. +5. 다른 업무 탭을 열어 둔 상태에서 반복해 다른 탭은 유지되고 활성 탭이 정상적으로 복귀하는지 확인한다. +6. GIS 편집 모드에서는 빈 영역을 클릭해도 지반침하 선택 해제가 오동작하지 않는지 확인한다. +7. 해제 후 다른 지반침하 포인트를 다시 선택해 상세와 반경 표시가 정상적으로 열리는지 확인한다. + +- 이 절차는 지도 포인트 선택 후 해제를 검증한다. 목록 행 선택만 한 상태는 지도 선택 모듈의 빈 선택 이벤트 대상이 아니므로 별도 시나리오로 구분한다. + +### 3.8 2026-07-15 관로 탭 진입 경로 분리 검증 계획 + +1. 지반침하와 무관한 지도 관로 A, B를 차례로 직접 선택해 관로 탭 2개가 각각 유지되는지 확인한다. +2. 같은 일반 관로를 다시 선택하면 중복 탭을 만들지 않고 기존 탭이 활성화되는지 확인한다. +3. 지반침하 상세에서 반경 내 관로 A, B를 차례로 선택해 부모 화살표가 있는 관로 탭 1개의 표시명과 상세만 B로 교체되는지 확인한다. +4. 화살표를 클릭하면 직전 부모 지반침하 탭으로 복귀하는지 확인한다. +5. 일반 관로 탭을 활성화해도 비활성 지반침하 연계 관로 탭의 화살표가 계속 표시되는지 확인한다. +6. 지도에서 직접 선택한 일반 관로 탭에는 화살표가 표시되지 않는지 확인한다. +7. `지반침하 + 연계 관로 + 일반 관로 3개` 상태에서 새 일반 관로를 열면 가장 오래된 부모 지반침하와 연계 관로가 함께 제거되고 일반 관로 4개가 남는지 확인한다. +8. 연계 관로 탭만 먼저 FIFO 또는 수동으로 제거할 때 부모 지반침하 탭은 유지되는지 확인한다. +9. 정적 검사는 TSX 구문 진단과 `git diff --check`로 수행하고, 상호작용은 로그인 가능한 `/map`에서 수동 확인한다. diff --git a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md new file mode 100644 index 000000000..5ddd15323 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md @@ -0,0 +1,335 @@ +# SFR-010-010 지반침하정보 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-010-010 지반침하정보`의 검증 결과를 기능당 1개 문서로 누적 관리한다. +- 이번 턴은 설계 단계이므로 실행 결과 대신 사전 점검 결과와 미실행 사유를 기록한다. +- 승인 후 구현과 실행 검증이 시작되면 이 문서에 라운드별 결과를 누적한다. + +## 2. 현재 수행 결과 + +### 2.1 설계 자료 점검 + +- 요구사항명세서, 시나리오, HTML, PNG를 읽고 화면 흐름과 API 초안을 정리했다. +- 현재 백엔드 작업트리에서 아래 선반영 범위를 확인했다. + - `GET /api/v1/sinkholes/geometry` + - IL datasource 분리 + - 주소 기반 지오코딩 서비스 +- 현재 프론트 코드에서는 `지반침하정보` 실구현이 없고, 실제 구현은 승인 후 착수해야 함을 확인했다. + +### 2.2 확인된 설계 리스크 + +- 화면에 필요한 상세 필드 일부가 현재 원천 테이블에 없다. +- 좌표 캐시가 현재 메모리 캐시라 최근 동기화 일시/상태 유지 요구를 그대로 만족하지 못한다. +- 목록 팝업을 별도 popup 창으로 열지, `/map` 내부 모달로 둘지 구현 기준이 아직 확정되지 않았다. + +### 2.3 2026-06-05 런타임 원인 확인 + +- 프론트에서 `GET /api/v1/sinkholes/geometry` 호출 시 500이 재현됐다. +- 같은 시점에 로컬 `GET /api/v1/sinkholes`, `GET /api/v1/sinkholes/{acdntNo}`를 직접 확인한 결과 `geocodeErrorMessage`에 아래 예외가 내려왔다. + - `Unrecognized field "adresTy" ... SinkholeNgiiResponse` +- 따라서 이번 런타임 장애의 1차 원인은 프론트 fetch 자체가 아니라 `reuse-service` 지오코딩 응답 필드 확장을 백엔드가 무시하지 못한 점으로 판정했다. +- `./gradlew compileJava --no-daemon`는 시도했으나 로컬 점유 중인 `spring-configuration-metadata.json` 때문에 `Failed to clean up stale outputs`로 중단돼, 재기동 후 최종 확인이 필요하다. + +### 2.4 2026-06-09 연도/전체 노드 코드 경로 확인 + +- 현재 프론트 지반침하 연도 필터 로직을 다시 확인한 결과, `tb_sn_lyr_stng`에서 내려오는 `2026년`, `전체` 자식 노드는 `sinkholeYear` 값이 없어 기존 코드가 연도 토큰을 읽지 못하고 있었다. +- 그래서 화면에 연도 노드가 보여도 초기 표출과 팝업 필터 동기화에서는 최근 3개월 기본 조건이 남아 있을 수 있는 상태로 판정했다. +- 이번 수정으로 `sinkholeYear`가 없더라도 노드 텍스트 `2026년`, `전체`를 각각 `2026`, `ALL`로 해석하도록 보강했다. +- 레이어 설정 초기 로딩도 지반침하 트리 보정 유틸을 실제로 거치도록 연결해, DB 자식 노드 유무와 상관없이 같은 정리 경로를 타게 했다. + +### 2.5 2026-06-09 발생기간 조회 시작일 2016년 확장 + +- 현재 팝업은 빈 조회조건일 때 최근 3개월을 기본값으로 쓰고 있어, 사용자가 장기 이력을 보려 해도 시작일을 다시 넓혀야 하는 상태였다. +- 이번 수정으로 프론트 기본 조회조건을 `2016-01-01 ~ 오늘`로 바꾸고, 날짜 입력 `min`도 `2016-01-01`로 고정했다. +- 백엔드도 같은 날 `최대 1년` 검증을 제거하고 `2016-01-01` 이전 날짜만 차단하도록 바꿨다. + +### 2.6 2026-06-09 다운로드 전체 결과 기준 코드 경로 확인 + +- 현재 지반침하 다운로드는 화면에 보이는 현재 페이지 `rows`만 엑셀로 내려가고 있어, 페이지네이션 이후 데이터는 누락되는 상태였다. +- 이번 수정으로 다운로드 시 현재 조회조건을 유지한 채 `/api/v1/sinkholes`를 최대 2000건 단위로 재호출해 전체 결과를 합친 뒤 엑셀을 생성하도록 변경했다. +- 아직 브라우저에서 2페이지 이상 결과를 실제로 내려받아 행 수를 대조하는 수동 검증은 수행하지 못했다. + +### 2.7 2026-06-09 다운로드 병목 재검토 + +- 백엔드 목록 API는 각 행마다 `SinkholeCoordinateResolver.resolve()`를 호출하고, 원천 TM 좌표가 없으면 외부 지오코딩을 타므로 대량 다운로드에서 이 경로가 지연의 핵심 병목이 될 수 있다고 판단했다. +- 그래서 동일 주소의 실패 지오코딩도 30분 단기 캐시에 저장하고, 프론트 다운로드 배치도 2000건으로 올려 1600건 규모에서는 목록 API 1회 호출에 가깝게 줄였다. +- 아직 실제 운영/개발 데이터 1600건 조건에서 전후 다운로드 시간을 계측하지는 못했다. + +### 2.8 2026-06-09 좌표 실패 건 상세 fallback 코드 경로 확인 + +- 좌표 실패 건 상세 클릭 시 `GET /api/v1/sinkholes/{acdntNo}`가 generic 500으로 실패하는 사례가 확인됐다. +- 이번 수정으로 목록 행 클릭 경로에서는 상세 API 실패 시 목록 row 데이터를 fallback 상세로 사용하고, 인접관로 조회 실패도 별도 warning으로만 남기도록 바꿨다. +- 추가로 row 자체가 이미 좌표 실패 상태이고 x/y도 없으면, 상세 API를 아예 생략하고 즉시 fallback 상세 패널을 열도록 바꿨다. +- 아직 실제 브라우저에서 실패 좌표 row 클릭 후 우측 패널이 fallback 데이터로 열리는지 재검증은 수행하지 못했다. + +### 2.9 2026-06-09 목록/다운로드 조회조건 변경 코드 경로 확인 + +- 기본 발생기간은 `2016-01-01` 고정이 아니라 다시 `현재 기준 최근 1년`으로 되돌렸다. +- 목록 조회영역에 `좌표보정상태` 조건을 추가했고, 목록 API query string도 해당 값을 함께 보내도록 바꿨다. +- 다운로드는 `includeCoordStatus=false`를 붙여 `좌표보정상태` 열 없이 현재 조회조건 전체 결과를 다시 받아 엑셀을 만들도록 변경했다. + +### 2.10 2026-06-09 조회옵션/헤더범위 마감 확인 + +- `좌표보정상태` 옵션에서 `주소없음`은 제거되고 `전체 / 성공 / 실패`만 남는 코드 경로를 확인했다. +- 지반침하 다운로드는 `headerRange=A1:I1`로 수정돼 첫 데이터 행이 헤더 스타일을 먹지 않도록 정리된 것을 확인했다. + +### 2.11 2026-06-09 팝업 활성 중 필터 우선순위 코드 경로 확인 + +- 평소 지도 레이어 조회는 트리 연도 체크 상태를 기준으로 하고, 팝업 sync 메시지를 받은 이후에만 팝업 필터가 우선 적용되도록 `isSinkholePopupFilterActive` 분기가 추가된 것을 확인했다. +- 팝업 close watcher와 레이어 해제 경로에서도 popup-active 상태를 해제하도록 맞춰, 팝업 종료 후 다시 트리 기준으로 복귀하는 코드 경로를 확인했다. + +### 2.12 2026-06-09 기능 테스트 최종 확인 + +- 사용자 확인 기준으로 아래 주요 흐름이 기대대로 동작함을 최종 확인했다. + - 지반침하 트리 기본 체크 상태 유지 + - `2026년` / `전체` 상위조건과 팝업 조회조건의 결합 + - 팝업 `목록보기` 오픈 시 지도 즉시 동기화 + - 팝업 닫힘 시 트리 기준 복귀 + - 팝업 열린 상태에서 레이어 재체크 시 조건 유지 + - 다운로드 첫 데이터 행 헤더 스타일 오염 제거 +- 현재 대화 범위에서는 기능 테스트 성공으로 판정한다. + +### 2.13 2026-06-11 목록 총 건수 헤더 표시 확인 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx` +- 확인 내용 + - 지반침하 팝업 목록 테이블이 `total` 상태값을 이미 보유하고 있는지 코드 경로를 다시 확인했다. + - 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고 `isHideCount={true}`를 적용해, 노후관로와 같은 문구 형식으로 총 건수가 노출되도록 보강했다. + - 사용자 확인 결과 반경이 정상 적용되었고, 요구한 총 건수 문구가 지반침하 팝업 상단에 표시되는 것을 확인했다. +- 검증 결과 + - 사용자 확인 완료 + - 추가 브라우저 재현 캡처와 빌드 재실행은 이번 라운드에서 수행하지 않음 + +### 2.14 2026-06-25 반경 내 관로정보 경과년도 컬럼 및 요약문구 코드 검증 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts` + - `BE_REPO_ROOT/src/main/java/iipbs/ms/domain/SinkholeNearbyPipeRepository.java` + - `BE_REPO_ROOT/src/main/java/iipbs/sewerage/api/sinkhole/dto/SinkholeNearbyPipeRowDto.java` +- 확인 내용 + - 백엔드 `nearbyPipes` row DTO에 `elapsedYears`가 추가되고 native query 결과 매핑 인덱스가 함께 조정됐는지 확인했다. + - 프론트 반경 내 관로정보 표에서 `용도` 컬럼을 제외하고 `경과년도` 컬럼과 제목 아래 요약문구(`건수`, `평균경과`, `최장경과` 값 파란색 볼드)가 추가되는지 코드 경로를 확인했다. + - `git diff --check`를 FE/BE 변경 파일 기준으로 실행해 공백 오류가 없음을 확인했다. + - 백엔드 `./gradlew.bat compileJava` 통과를 확인했다. +- 미확인 내용 + - FE `tsc --noEmit`은 로컬 `tsc` 명령 부재로 실행하지 못했다. + - 실제 브라우저에서 4개 컬럼과 요약문구가 잘리지 않는지, API 응답 `elapsedYears` 값으로 평균경과/최장경과가 계산되는지는 개발서버 수동 검증이 필요하다. + +### 2.15 2026-06-25 반경 조회 회귀 방지 확인 +- `map/page.tsx`에 추가했던 반경 조회 fallback 변경을 제거하고, 기존 조회 흐름으로 원복했음을 확인했다. +- FE 컬럼 추가와 타입 추가, BE 응답 필드 추가만 남긴 상태에서 `git diff --check`를 다시 수행한다. +- 실제 브라우저에서 사고번호 `20260026`처럼 기존에 24건이 나오던 케이스가 그대로 유지되는지 재확인이 필요하다. + +### 2.16 2026-06-26 레이어 연도범위 설정 전환 사전 분석 결과 + +- 이번 턴은 구현 전 분석 단계이므로 코드 수정, 브라우저 실행, API 호출 검증은 수행하지 않았다. +- 기존 문서와 코드 경로를 대조한 결과, 현재 지반침하 레이어 조회는 `2026년`/`전체` 자식 노드와 `applySinkholeYearFilters`에 의존하는 구조가 남아 있음을 확인했다. +- `SinkholeLayerSettingPanel`에는 현재 반경 입력만 있고 시작/종료년도 입력이 없어, 다음 구현에서 패널 props와 상태를 확장해야 한다. +- BE `SinkholeListSearchDto`와 `SinkholeQueryService`는 `startYmd`, `endYmd`, 최소일 검증을 이미 제공하므로 신규 BE 계약은 필요하지 않은 것으로 판정했다. +- `COMMON_DOCS_ROOT/DB작업.sql`에는 `2026년`, `전체` 자식 레이어 생성/아이콘 보정 DML이 남아 있어, 다음 구현에서 기준 데이터 삭제/비활성화 DML을 함께 정리해야 한다. +- 다음 검증은 구현 후 `지반침하정보` 루트 on/off, 설정패널 연도 범위 API 파라미터, 팝업 초기조건 동기화, 기존 반경/상세 회귀 순서로 수행한다. + + +### 2.17 2026-06-26 레이어 연도범위 설정 전환 구현 검증 결과 + +- 코드 검색 결과 `2026년`/`전체` 합성 연도 노드 의존 심볼(`buildSinkholeYearNodes`, `isSinkholeYearNode`, `applySinkholeYearFilters`)은 지도 화면 코드에서 제거됐다. +- 변경 TS/TSX 3개 파일(`page.tsx`, `SinkholeLayerSettingPanel.tsx`, `sinkholeTypes.ts`)은 로컬 TypeScript 파서 기준 diagnostics 0건을 확인했다. +- 기존 dev 서버에서 `http://localhost:3002/map` 요청이 200으로 응답해 페이지 컴파일 진입이 가능함을 확인했다. +- `git diff --check`는 FE/공통 변경 파일 기준 공백 오류 없음으로 통과했다. Git 줄바꿈 정책 경고는 일부 파일에서만 출력됐다. +- `next lint`와 `tsc --noEmit`는 현재 프로젝트 도구 설정 문제로 완료하지 못했다. 실제 로그인 화면에서의 레이어 트리/Network query string 검증은 후속 수동 검증으로 남긴다. + + + +### 2.18 2026-06-26 상세 fallback 및 설정 UI 보정 검증 결과 + +- `page.tsx`와 `SinkholeLayerSettingPanel.tsx` 변경분을 TypeScript 파서로 확인했고 diagnostics 0건이었다. +- 기존 dev 서버에서 `http://localhost:3002/map` 요청이 200으로 응답했다. +- `git diff --check`는 변경 FE 파일 기준 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. +- 상세 API 500 상황에서 fallback이 실제 화면에서 패널을 유지하는지, 반경 적용 후 Network 요청이 좌표 기반으로 나가는지는 로그인 세션이 있는 브라우저에서 후속 수동 확인이 필요하다. + + +### 2.19 2026-06-26 우측 상세 패널 공통 탭 구현 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - `RightPanelTab`, `RightPanelBookmarkTabs`, `rightPanelTabs`, `activeRightPanelTabId`가 추가되어 오른쪽 상세 패널의 열린 상세를 탭으로 관리하는 코드 경로를 확인했다. + - 탭이 2개 이상일 때만 책갈피 UI가 렌더링되고, 탭 클릭 시 활성 payload를 기준으로 `FeatureClickEventPopup`, `SinkholeDetailPanel`, `GisEditPipeSessionPopup` 중 해당 상세를 렌더링하는 구조를 확인했다. + - 지반침하 반경 내 관로 클릭 경로에서 `restoreSinkholeDetailOnCloseRef` 기반 복귀 대신 탭 상태로 전환할 수 있도록 플래그 의존을 제거했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 지반침하 상세 → 반경 내 관로 ID 클릭 → 책갈피 탭 2개 표시 → 탭 전환 → 현재 탭 닫기 흐름은 수동 확인이 필요하다. + - `pnpm --filter sn exec tsc --noEmit`는 기존 `tsconfig.json`의 `ignoreDeprecations` 값 오류와 `tsc` 실행 경로 문제로 변경 코드 타입 검증까지 도달하지 못했다. +### 2.20 2026-06-26 우측 상세 패널 탭 식별값 보강 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - 지반침하 탭은 `SinkholeDetail.acdntNo` 또는 feature 사고번호를 `subTitle`로 저장해 제목 아래에 표시하는 코드 경로를 확인했다. + - 하수관로 탭은 `fclt_mng_no`, `fcltMngNo`, `pipeId`, `fid` 순서로 관리번호 후보를 읽어 제목 아래에 표시하는 코드 경로를 확인했다. + - 보조 식별값이 없으면 제목만 렌더링되어 다른 상세 탭의 표시가 깨지지 않는 구조를 확인했다. + - 탭 폭과 최소 높이를 조정해 숫자 표시 공간을 확보했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 지반침하/하수관로 탭의 실제 표시 폭과 줄바꿈은 수동 확인이 필요하다. +### 2.21 2026-06-29 우측 상세 패널 탭 최대 개수 제한 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - `RIGHT_PANEL_TAB_LIMIT=5` 기준으로 우측 상세 패널 탭 상태가 5개를 초과하지 않도록 제한하는 코드 경로를 확인했다. + - 5개가 열린 상태에서 새 상세를 추가하면 새 탭과 현재 활성 탭을 보호하고, 오래 사용하지 않은 일반 상세 탭을 우선 제거하는 구조를 확인했다. + - 지반침하 탭은 기준 상세로 쓰일 가능성이 높아 일반 상세 탭보다 늦게 제거되고, GIS 편집 탭은 자동 제거 우선순위가 낮은 구조를 확인했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 6번째 상세 클릭 시 실제로 가장 오래된 일반 탭이 교체되는지는 수동 확인이 필요하다. +### 2.22 2026-06-29 우측 상세 패널 탭 전환 지도 선택 동기화 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - 탭 전환 함수가 활성 탭의 feature payload를 기준으로 지도 선택/강조 동기화 함수를 호출하는 코드 경로를 확인했다. + - 하수관로 탭 전환 시 기존 `SN_SINKHOLE_FOCUSED_PIPE_LAYER`를 비우고 해당 관로 feature를 다시 그린 뒤 선택 상태로 맞추는 구조를 확인했다. + - 지반침하 탭 전환 시 관로 강조 레이어를 지워 이전 관로 선택 표시가 남지 않도록 하는 구조를 확인했다. + - 반경 내 관로 ID 최초 클릭 경로도 같은 동기화 함수를 사용하도록 정리된 것을 확인했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 탭을 번갈아 클릭할 때 지도 관로 강조선이 실제로 따라 바뀌는지는 수동 확인이 필요하다. +### 2.23 2026-06-29 지도 직접 선택 시 지반침하 관로 포커스 해제 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - 일반 지도 피처 선택 함수 진입 시 지반침하 반경 내 관로 ID 클릭으로 만든 `SN_SINKHOLE_FOCUSED_PIPE_LAYER`를 먼저 정리하는 코드 경로를 확인했다. + - 반경 내 관로 ID 클릭과 책갈피 탭 전환은 기존 동기화 함수를 통해 필요한 관로 포커스를 다시 그리므로, 직접 지도 선택과 탭 전환의 역할이 분리되는 구조를 확인했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 파란 포커스 관로가 표시된 상태로 주변 관로를 직접 클릭했을 때 파란 선이 실제로 사라지는지는 수동 확인이 필요하다. +## 3. 미실행 항목 + +- FE 브라우저 검증 +- BE API 호출 검증 +- 반경 내 관로 거리 계산 검증 +- 관리자 권한 분기 검증 +- 다운로드 파일 검증 +- FE 빌드 검증 +- BE compile/test 최종 검증 + +미실행 사유: + +- 작성자 승인 전 구현 금지 지시 +- 이번 턴 범위가 설계 및 문서 초안 작성까지로 제한됨 +- 이번 유지보수 라운드에서는 코드 경로 점검과 최소 수정까지만 수행했고, 개발 서버 실행 여부를 확인하지 못해 FE 빌드를 별도로 돌리지 않았다. + +## 4. 현재 상태 판정 + +- 설계 검토: 완료 +- 코드 구현: 완료 +- 기능 테스트: 성공 +- 브라우저/API 정밀 재검증: 일부 미실행 + +## 5. 후속 실행 조건 + +- 작성자 구현 승인 +- FE/BE 코드 반영 완료 +- 로그인 가능한 FE/BE 실행 환경 준비 +- 관리자/일반 사용자 테스트 계정 준비 + +### 2.24 2026-07-02 잔여 159건 좌표 실패 원인 분류 결과 + +- 원천 좌표 null 사고 `159건`을 기준으로 실제 JIS와 기존 주소기반 geocoder를 다시 대조했다. +- JIS 확인 결과 + - 최신 성공 예시 `20260040`은 `selectInfo.do` 성공 응답과 `mapMainAdmin.do`의 `poly_lonlat=POINT(...)`를 확인했다. + - 대표 구 사고번호 `20200232`, `20200018`, `20200093`, `20200068`, `20200063`는 `selectInfo.do` 응답이 `{"result":"fail","reason":null}`였다. +- 따라서 JIS 브라우저 콘솔의 `mapNew.js ... clone` 오류는 잔여 159건 실패의 공통 원인이 아니라, 성공 케이스에서도 발생 가능한 별도 프론트 스크립트 오류로 분리했다. +- 주소 기반 geocoder 재측정 결과 + - 총 159건 중 `49건 성공`, `110건 실패` + - 실패 110건은 설명형/비정형 주소 비중이 높고, `위경도 값이 없는 반환값 수신`도 20건 있었다. +- `정제주소안`으로 110건을 재시도했지만 `추가 성공 0건`이었다. +- 현 시점 판정 + - JIS 우선 구현은 최신 사고번호 대응에는 유효하다. + - 구 사고번호 공백을 줄이려면 주소 fallback을 함께 유지해야 한다. + - 남는 110건은 단순 주소 정제로 해결되지 않아 별도 데이터 보강 또는 수기 좌표 보정 대상으로 분류한다. +### 2.25 2026-07-13 우측 상세 탭 유지보수 검증 결과 + +- `git diff --check`: 통과. +- TypeScript + - 기본 `tsc`는 기존 `ignoreDeprecations: "6.0"`과 TypeScript 5.9.3 불일치로 시작 단계에서 실패했다. + - `--ignoreDeprecations 5.0`으로 재실행해 변경된 `map/page.tsx` 신규 오류 0건을 확인했다. + - 전체 종료는 기존 공통 컴포넌트, floodHistory, `app/html/**`, 공통 chart 파일의 선행 오류 때문에 실패했다. +- Next build + - optimized production compile 28.1초 성공. + - 정적 페이지 19개 생성 성공. + - standalone trace 복사에서 Windows symlink 권한 `EPERM`이 발생해 최종 exit code 1. +- ESLint + - 파일 단위 실행 전 기존 `eslint.config.mjs` 로딩 TypeError가 발생해 미실행. +- 브라우저 + - 로컬 dev 서버는 6.2초에 Ready. + - `/map` 최초 컴파일이 1분 이상 완료되지 않아 브라우저 탐색과 실제 탭 클릭은 환경 차단. +- 정적 코드 확인 + - 관로 singleton ID, 부모 ID 저장/정리, 관로 표시명 옆 소형 뒤로가기 버튼, 유형별 색상 map, 괄호형 표시명 resolver가 구현된 것을 확인했다. + - BE API·DTO·DB 변경이 없음을 확인했다. +- 판정 + - 코드 구현 및 컴파일: 완료. + - 실브라우저 상태 전이: 후속 수동 검증 필요. + +### 2.26 2026-07-14 부모 복귀 화살표 및 처리구역 API 회귀 검증 + +- `RightPanelBookmarkTabs`에서 화살표 조건이 `pipe && isActive && parentTab`으로 제한됐고 크기가 `h-6 w-6 text-sm`로 변경된 것을 확인했다. +- 부모 지반침하 탭 활성화 시 관로 탭의 `isActive`가 false가 되어 화살표가 숨겨지는 상태 전이를 정적으로 확인했다. +- 탭 최대 수는 5개이며 제거 후보 함수가 보호 ID와 `lastActiveAt` 및 탭 종류를 함께 사용하므로 엄격한 FIFO가 아님을 확인했다. +- TypeScript compiler API로 `page.tsx`를 TSX 파싱한 결과 diagnostics 0건이었다. +- 프론트 `git diff --check`는 통과했다. +- 백엔드 `compileJava`는 성공했다. +- 사용자 요청에 따라 서버와 브라우저는 실행하지 않았다. +- 남은 수동 검증은 실행 중 통합 환경에서 관로→부모 지반침하 복귀 후 화살표 숨김, 처리구역 클릭 200 응답과 분석 표 표시를 확인하는 것이다. + +### 2.27 2026-07-14 지도 UX 유지보수 구현 확인 및 사용자 검증 대기 + +- 구현 파일의 변경 diff에서 반경 라벨 피처, 트리 심볼 fallback/선택 prop, 빈 선택 결과 콜백, 목록 사고번호 첫 열을 정적으로 확인했다. +- 공통 빈 선택 결과는 `GisSelect` 한 곳에서만 소비하며 콜백 미전달 화면은 기존처럼 아무 동작도 하지 않는 구조를 확인했다. +- `pnpm --filter sn exec tsc --noEmit --pretty false --ignoreDeprecations 5.0`은 실행했으나 기존 common/floodHistory/html/chart 오류로 전체 성공하지 않았다. +- 검사에서 함께 확인된 `page.tsx`의 GeoServer storage null 가능성은 같은 지반침하 관로 조회 흐름에서 방어 처리했다. +- 개발 서버가 실행 중이어서 저장소 규칙에 따라 Next 및 `business_components` 빌드는 수행하지 않았다. +- 사용자 요청에 따라 브라우저·API·화면 상호작용 검증은 수행하지 않았다. +- 현재 판정은 `구현 완료 / 사용자 수동 검증 대기`다. +- 사용자 확인 항목은 반경 라벨 값과 시인성, 트리 파란 심볼, 빈 지도 클릭 시 선택 해제와 다른 탭 보존, 목록 첫 열 사고번호다. + +### 2.28 2026-07-14 목록 NO 열 제거 및 수동 검증 이관 + +- `SinkholePopupPage.tsx`의 `HEADLIST`에서 `acdntNo`가 첫 열이고 `rowNo/NO` 열이 없는 것을 정적으로 확인했다. +- 내부 `rowNo` 생성과 다운로드 순번은 기존 호환을 위해 유지했다. +- 지도 선택 해제의 구체적인 수동 절차와 기대 결과를 통합검증계획 3.7에 추가했다. +- 브라우저·API·빌드는 사용자 요청에 따라 실행하지 않았으며 현재 판정은 `구현 완료 / 사용자 수동 검증 대기`다. + +### 2.29 2026-07-14 사용자 통합 검증 결과 + +- 검증 주체: 사용자 +- 검증 환경: 실행 중인 통합 화면 +- 반경 표시 영역에 실제 반경 값이 정상 표시됨을 확인했다. +- 지반침하 레이어 트리에 심볼이 정상 표시됨을 확인했다. +- 지도에서 선택한 지반침하가 빈 지도 영역 클릭으로 정상 해제되고 관련 반경·강조 표시도 함께 정리됨을 확인했다. +- 지반침하발생현황 목록 첫 열에 사고번호가 표시되고 화면 `NO` 열이 제거된 것을 확인했다. +- 이번 유지보수 요구사항 전체가 성공했으므로 최종 판정은 `완료`다. + +### 2.30 2026-07-15 관로 탭 진입 경로 분리 정적 검증 결과 + +- `buildRightPanelTab`에서 부모가 있는 관로만 `RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID`를 사용하고 일반 관로는 피처 식별값 기반 ID를 사용하는 것을 확인했다. +- 지도 직접 선택 시 `pipeParentTabIdRef.current = null`로 부모 연결을 해제하는 기존 경로를 확인했다. +- 부모 탭이 있는 관로는 활성 여부와 무관하게 `←` 버튼을 렌더링하도록 변경된 것을 확인했다. +- 자동 정리 후보가 탭 배열의 첫 항목을 선택해 유형·활성 상태와 관계없이 FIFO로 동작하는 것을 확인했다. +- `removeRightPanelTabGroup`이 제거 대상 ID와 그 ID를 `parentTabId`로 가진 탭을 함께 제외해 부모 지반침하 제거 시 연계 관로도 사라지는 것을 확인했다. +- 연계 관로 자체를 제거 대상으로 전달하면 부모 탭은 조건에 걸리지 않아 유지되는 단방향 종속 구조를 확인했다. +- TypeScript compiler API의 `transpileModule`로 `page.tsx`를 TSX 파싱한 결과 error diagnostics 0건이었다. +- 프론트 변경 파일 대상 `git diff --check`는 통과했다. +- 로그인 세션이 필요한 실제 탭 누적·교체·복귀 상호작용은 수동 검증이 남아 있다. diff --git a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md new file mode 100644 index 000000000..220ae751c --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md @@ -0,0 +1,27 @@ +# SFR-011-002 관로분석 FE 통합검증결과 + +## 1. 검증 목적 +- 관로분석 프론트 변경이 오류 전달 구조를 깨지 않고 유지되는지 확인한다. + +## 2. 수행한 검증 +- 코드 리뷰로 `PipeAnalysisPopupPage`, `PipeAnalysisDetailPopupPage`, `getData`의 예외 흐름을 확인했다. +- `pnpm --filter sn exec tsc --noEmit`를 실행했다. + +## 3. 검증 결과 +- `PipeAnalysisPopupPage.tsx`의 엑셀 다운로드 캐스팅 경고는 수정 후 해소했다. +- 집계/상세 팝업은 이제 `Error.message`가 존재하면 공통 경고문 대신 해당 메시지를 노출한다. +- 관로분석 집계 헤더의 `총 N건` 문구가 제거됐다. +- 집계 팝업은 10개 단위 페이지 렌더링으로 바뀌어 한 화면에 그리는 행 수를 제한한다. +- 전체 TypeScript 검증은 저장소 기존 오류로 실패했다. + +## 4. 기존 오류 메모 +- `app/(map-layout)/map/components/common/index.ts` 타입 불일치 +- `app/html/(with-base-layout)/layout.tsx` import 누락 +- `packages/common/business_components/src/ui/chart/*` implicit any + +## 5. 미수행 검증 +- 로컬 브라우저에서 실제 관로분석 팝업을 열어 세 분석구분을 재조회하는 수동 검증은 이번 턴에서 수행하지 못했다. + +## 6. 판단 +- 프론트 수정 범위 내에서 신규 타입 오류를 만들지는 않았다. +- 실제 장애 해소 여부의 최종 판정은 백엔드 반영본과 함께 브라우저 재조회 확인이 필요하다. diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/001-qa-SFR-011-007-통수위험분석-통합검증계획.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/001-qa-SFR-011-007-통수위험분석-통합검증계획.md new file mode 100644 index 000000000..bf4692aba --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/001-qa-SFR-011-007-통수위험분석-통합검증계획.md @@ -0,0 +1,93 @@ +# SFR-011-007 통수위험분석 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-011-007 통수위험분석`의 FE 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 FE-only 프로토타입 구현 완료 기준의 검증 범위와 후속 통합 검증 포인트를 정의한다. + +## 2. 검증 기준 요약 + +- `통수위험` 아이콘으로 기능 진입 가능 +- 결과 조회 popup 열림/닫힘 동작 +- 날짜/시간 필수값 검증 +- 위험상태 필터 동작 +- 관로 1건, 2건, 3건 동적 컬럼 렌더링 +- 결과 행 선택 시 지도 강조 및 상세 popup 갱신 +- 예측강우 sample grid overlay 노출 +- 결과/설정 목록 페이지네이션 노출 +- 설정 현황 조회와 staged 설정 편집 진입 +- 유출관로 중복 선택 차단 +- 최소 1건, 최대 3건 저장 규칙 +- 저장 실패 시 staged 목록 유지 + +## 3. 라운드별 검증 계획 + +### 3.1 설계 승인 직후 FE-only 검증 + +- popup 진입/전환/닫기 +- 조회조건 유효성 +- 결과 그리드 렌더링 +- 위험/정상 스타일 전환 +- 예측강우 sample overlay 가시성 +- 설정 현황 목록과 staged 유출관로 편집 +- 저장 payload preview +- 상세정보 카드의 표 구조, 위치, 강조 규칙 + +### 3.2 BE 연동 후 통합 검증 + +- 실제 조회조건 전달 +- 실제 geometry와 격자 overlay 정합성 +- 설정 저장/재조회 +- 결과/설정 다운로드 +- 권한 범위 제어 + +## 4. 현재 검증 범위 + +### 4.1 진행 전제 + +- FE-only 프로토타입 코드는 구현 완료 상태다. +- 자동 브라우저 검증은 환경 제약으로 제한적이므로, 현재는 코드 점검과 사용자 피드백 기반 수동 검증 포인트를 우선 기록한다. + +### 4.2 자동 또는 반자동 관측 포인트 + +| 구분 | 관측 포인트 | 기대 결과 | +| --- | --- | --- | +| 진입 | `통수위험` 버튼 | popup 열림 | +| 결과 | 날짜/시간 누락 | 조회 차단 메시지 | +| 결과 | 위험상태 필터 | 결과 건수와 목록 반영 | +| 결과 | 동적 관로 컬럼 | 1~3건 반복 렌더링 | +| 결과 | 페이지네이션 | 결과 2페이지, 설정 3페이지 이상 노출 | +| 지도 | 선택 지역 | 중심 이동, 강조 색상 반영 | +| 지도 | 예측강우 격자 | sample overlay 표시 | +| 지도 | 상세정보 카드 | 표 구조, 숫자 강조, 중점관리지역 오른쪽 anchor 동작 확인 | +| 설정 | 설정 현황 전환 | 탭 또는 화면 전환 성공 | +| 설정 | 유출관로 선택 | staged 목록 누적 | +| 설정 | 4번째 관로 선택 | 차단 메시지 | +| 저장 | payload preview | focusAreaId, pipes, setSeq 구조 확인 | + +## 5. 차단 사유 분류 기준 + +- `기능 결함` + - 기대한 상태 전이나 렌더링이 깨짐 +- `인증 차단` + - 로그인 또는 권한 문제로 진입 불가 +- `환경 차단` + - dev 서버, API, 지도 리소스 미기동 +- `데이터 차단` + - 지역 목록, 샘플 결과, geometry 부족 +- `자동화 한계` + - 지도 canvas overlay 체감 확인이 도구상 제한적 + +## 6. 다음 검증 순서 + +1. 결과 조회 popup 열림/닫힘 +2. 조회조건 필수값/초기화 +3. 동적 관로 컬럼 렌더링 +4. 결과/설정 목록 페이지네이션 +5. 결과 행 선택과 지도 강조 +6. 예측강우 sample overlay +7. 상세정보 카드 레이아웃과 중점관리지역 오른쪽 anchor 규칙 +8. 설정 현황 목록 조회 +9. staged 유출관로 1~3건 제어 +10. 저장 payload preview +11. BE 연동 후 실제 저장/다운로드 diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md new file mode 100644 index 000000000..add9608c9 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md @@ -0,0 +1,116 @@ +# SFR-011-007 통수위험분석 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-011-007 통수위험분석`의 FE 검증 결과를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 설계 초안 점검부터 FE-only 프로토타입 코드 점검, 수동 피드백 반영 상태까지의 결과를 기록한다. + +## 2. 수행한 검증 요약 + +### 2.1 2026-05-18 설계 단계 점검 + +- 수행 범위 + - 작업지시 문서 해석 + - 템플릿 문서 구조 확인 + - 공통/FE 문서 세트 정의 + - 예상 수정 파일, FE-only 범위, BE 필요 범위 분리 +- 미수행 범위 + - FE 코드 구현 + - 브라우저 검증 + - API 응답 확인 + +### 2.2 2026-05-18 FE-only 1차 구현 점검 + +- 코드 기준 확인 + - `flowRiskPopup` registry가 실제 `WaterRiskPopupContent`로 연결됐다. + - `WaterRiskPopupPage.tsx`에서 결과 조회/설정 현황/staged 편집/preview UI가 구현됐다. + - `map/page.tsx`에서 popup sync, 선택 지역 강조, sample rain grid overlay, 설정 편집 중 하수관로 선택 인터셉트가 구현됐다. +- 타입체크 + - `NODE_OPTIONS=--max-old-space-size=4096 pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 이번 변경분 관련 신규 오류는 제거됐다. + - 잔여 오류: 저장소 기존 오류만 남음 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - `app/html/**` 기존 타입/모듈 오류 + - `packages/common/business_components` chart implicit any +- 미수행 + - 실제 브라우저 동작 검증 + - 실제 API 연동 검증 + +### 2.3 2026-05-19 FE 프로토타입 UI 보완 점검 + +- 코드 기준 확인 + - 결과 조회/설정 현황 mock 데이터가 확대되어 페이지네이션이 실제로 노출되는 상태로 바뀌었다. + - 결과/설정 toolbar 버튼 정렬, 헤더 컬러, 본문 행 배경이 프로토타입 기준으로 정리됐다. + - 상세정보 카드는 표 구조, compact spacing, 유출유량 초과 강조 등 사용자 피드백 기반 보완이 반영됐다. +- 수동 피드백 반영 + - 페이지네이션과 상세정보 카드 UI는 사용자 확인 기준으로 여러 차례 보정됐다. + - 상세정보 카드의 지도 기준 고정 규칙은 현재도 추가 브라우저 확인이 필요한 항목으로 남아 있다. +- 미수행 + - 자동 브라우저 캡처 기반 검증 + - 실제 API 응답/저장/다운로드 검증 + +### 2.4 2026-05-19 FE 추가 검증 실행 + +- 로컬 응답 확인 + - `http://localhost:3002/popup/flowRiskPopup` + - 결과: HTTP 200 응답 확인 +- 타입체크 + - 실행 위치: `frontend/apps/sn` + - 실행 명령: `.\node_modules\.bin\tsc.cmd --noEmit --incremental false --pretty false` + - 결과: 이번 통수위험분석 변경으로 새로 유입된 타입 오류 1건(`buildWaterRiskInfoAnchor`)을 수정했고, 재실행 후 남은 오류는 저장소 기존 오류만 확인됐다. + - 잔여 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` props 타입 불일치 + - `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` `JSON[]` 캐스팅 경고 + - `app/html/(map-layout)/map/components/common/index.ts` props 타입 불일치 + - `app/html/(with-base-layout)/layout.tsx` 모듈 경로 오류 + - `packages/common/business_components/src/ui/chart/*.tsx` implicit any +- 미수행 + - 지도 상호작용을 포함한 실제 브라우저 자동 검증 + - 실제 API 연동 검증 + +### 2.5 2026-05-19 상세정보 카드 상호작용 보정 + +- 코드 기준 확인 + - 상세정보 카드는 선택된 중점관리지역 geometry를 기준으로 오른쪽 anchor를 잡는 방식으로 재조정됐다. + - 지도 렌더링 중에는 같은 anchor를 기준으로 카드 위치를 갱신하는 구조로 정리됐다. +- 수동 피드백 반영 + - 사용자 기준으로 “화면 고정”이 아니라 “중점관리지역 오른쪽에 붙은 상태로 이동”해야 한다는 요구를 반영했다. +- 미수행 + - 실제 브라우저 drag/zoom 상황에서 최종 체감 확인 + +## 3. 현재 검증 상태 + +### 3.1 통과 + +- 문서 구조가 `SFR-012-003` 템플릿 기준과 맞는지 확인 +- 공통 설계서, 공유기록, FE 작업카드, 구현기록, 검증 계획/결과 문서 세트 정의 완료 +- 구현 승인 전 코드 미수정 원칙 반영 +- FE-only 프로토타입 범위의 결과 조회/설정 현황/지도 강조/staged 편집 코드 반영 완료 +- 로컬 `flowRiskPopup` 경로의 HTTP 200 응답 확인 +- 이번 통수위험분석 변경으로 인한 신규 타입 오류 제거 + +### 3.2 미실행 + +- 실제 브라우저에서의 `통수위험` 아이콘 진입 +- 실제 popup 렌더링 재확인 +- 실제 지도 drag/zoom 상태에서 상세정보 카드 anchor 규칙 재확인 +- 실제 API 연동 검증 + +### 3.3 보류 + +- 실제 BE 연동 검증 +- 다운로드 파일 검증 +- 권한 범위 검증 + +## 4. 보류/리스크 + +- 상세정보 카드 anchor 규칙은 FE 프로토타입 단계에서 반복 조정된 항목이라 운영 규칙 확정 전 재검토가 필요하다. +- 동적 관로 컬럼과 지도 overlay는 실제 API 응답 구조 확정 전까지 mock 기준으로만 검토 가능하다. +- 실제 다운로드/저장 후처리와 파일 결과는 아직 확인되지 않았다. + +## 5. 다음 검증 착수 조건 + +- dev 서버 또는 로컬 미리보기 진입 가능 +- mock 데이터 또는 초기 API 응답 구조 준비 +- 인증 가능한 브라우저 환경에서 지도 조작 수동 검증 가능 diff --git a/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md new file mode 100644 index 000000000..e80066fa9 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/001-qa-SFR-011-009-침수발생이력-통합검증계획.md @@ -0,0 +1,79 @@ +# SFR-011-009 침수발생이력 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 FE 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 docs-first 설계 완료 직후 기준으로, FE-only 범위 검증 항목과 후속 통합 검증 항목을 분리한다. + +## 2. 검증 기준 요약 + +- 레이어 트리 `침수흔적도` 목록 popup이 정상적으로 열린다. +- `침수흔적도` on/off가 지도 표시와 맞게 동작한다. +- 조회조건 유효성 검증이 동작한다. +- `침수발생년도` 선택이 동작한다. +- 목록이 조건에 따라 필터링된다. +- 목록 다운로드가 현재 조회 결과 기준으로 동작한다. +- 행 클릭 시 메인 지도 preview가 뜬다. +- 지도 위 큰 침수흔적 레이어 클릭 시 작은 상세 팝업이 열린다. + +## 3. FE-only 검증 계획 + +### 3.1 popup 진입 + +- 레이어 트리 `침수흔적도` 체크 +- 레이어 트리 `침수흔적도`의 목록 버튼 클릭 +- popup open 여부 +- popup 제목, 조회조건 영역, 목록 그리드 노출 여부 + +### 3.2 조회조건 검증 + +- 시도 미선택 상태에서 시군구 선택 후 조회 시 차단되는지 확인 +- 침수발생년도 선택 시 목록 필터가 반영되는지 확인 +- 침수재해명 키워드 입력 시 목록 필터가 적용되는지 확인 + +### 3.3 목록/다운로드 + +- 조회 결과 총 건수 표시 +- `강우 분석정보` 묶음 헤더와 하위 4개 컬럼 표시 +- 나머지 상세 필드가 가로 스크롤로 모두 노출되는지 확인 +- 10건 초과 시 페이지네이션 노출 +- 다운로드 버튼 클릭 시 현재 필터링 결과 기준 파일이 생성되는지 확인 +- 결과 없음 상태에서 다운로드가 차단되는지 확인 + +### 3.4 지도 연동 + +- 행 클릭 시 지도 preview 표시 여부 +- 행 더블클릭 시 popup close 여부 +- 메인 지도 영역으로 fit 이동되는지 확인 +- `침수흔적도` 체크 시 mock polygon과 침수심 overlay가 함께 표시되는지 확인 +- `침수흔적도`를 끄면 관련 표시가 함께 숨겨지는지 확인 +- 큰 침수흔적 polygon 클릭 시 상세 팝업이 뜨는지 확인 + +### 3.5 지도 상세 팝업 + +- `침수년도탭`, `재해명탭`, `침수발생강우탭` 전환 +- 기술 컬럼 제외 후 한글 업무 항목만 보이는지 확인 +- 각 탭의 테이블 구조와 값 표시 +- 닫기 시 팝업만 닫히는지 확인 + +## 4. BE 연동 후 검증 계획 + +- 실제 목록 조회 API 필드 매핑 +- 실제 geometry 및 지도 위치 정합성 +- 지도 click 상세조회 +- 실제 레이어 트리 `침수흔적도` 토글 연계 +- 다운로드 권한 및 파일 포맷 검증 + +## 5. 차단 사유 분류 기준 + +- `기능 결함` +- `인증 차단` +- `환경 차단` +- `데이터 차단` +- `자동화 한계` + +## 6. 현재 상태 + +- 공통 설계서 작성 완료 +- FE 작업카드 작성 완료 +- 구현은 진행 중이며, 검증은 FE-only 기준으로 먼저 수행한다. diff --git a/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md new file mode 100644 index 000000000..ca7d05fd6 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-009-침수발생이력/002-qa-SFR-011-009-침수발생이력-통합검증결과.md @@ -0,0 +1,52 @@ +# SFR-011-009 침수발생이력 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 현재 검증 결과와 남은 수동 확인 항목을 누적 관리한다. +- 현재 문서는 FE draft 구현 직후 기준의 로컬 응답 확인과 자동화 한계 항목을 기록한다. + +## 2. 이번 라운드 결과 요약 + +- 상태: 부분 확인 +- 확인 완료 + - `localhost:3002` 기본 응답 200 확인 + - `localhost:3002/map` 응답 200 확인 + - 공통 설계서, FE 작업카드, FE 검증계획 작성 완료 +- 미완료 + - popup 실제 진입 확인 + - 목록 조회/행 클릭 preview/메인 지도 플로팅 상세팝업 수동 확인 + - 다운로드 파일 수동 확인 + +## 3. 세부 결과 + +### 3.1 환경 확인 + +- `pnpm --filter sn run dev`는 샌드박스 내에서 `spawn EPERM`으로 실패했다. +- 권한 상승 후 로컬 서버를 열어 `http://127.0.0.1:3002`, `http://127.0.0.1:3002/map` 응답 200을 확인했다. + +### 3.2 자동/간접 확인 + +| 항목 | 결과 | 메모 | +| --- | --- | --- | +| 로컬 서버 기본 응답 | 통과 | `/` 200 | +| 지도 메인 경로 응답 | 통과 | `/map` 200 | +| popup 경로 직접 요청 | 보류 | 초기 컴파일 지연 또는 자동 요청 timeout 가능성 | +| 실제 popup 클릭 | 보류 | 사용자가 로컬 수동 검증 예정 | +| 행 클릭 preview | 보류 | 사용자가 로컬 수동 검증 예정 | +| 더블클릭 후 지도 이동 | 보류 | 사용자가 로컬 수동 검증 예정 | +| 지도 클릭 플로팅 팝업 | 보류 | 사용자가 로컬 수동 검증 예정 | + +## 4. 남은 수동 검증 항목 + +1. 레이어 트리 `침수흔적도` 목록 버튼으로 popup이 정상적으로 열리는지 확인 +2. 조회조건 입력과 목록 필터가 기대대로 동작하는지 확인 +3. 목록 행 클릭 시 메인 지도에 테스트 레이어가 보이는지 확인 +4. 목록 행 더블클릭 시 popup이 닫히고 메인 지도 이동이 유지되는지 확인 +5. 큰 침수흔적 polygon 클릭 시 작은 상세 팝업이 뜨는지 확인 +6. `침수흔적도` on/off에 따라 영역/침수심 overlay가 메인 지도에 맞게 보이는지 확인 +7. 상세 팝업 탭과 표 구성이 자연스러운지 확인 + +## 5. 결론 + +- FE draft 구현과 문서 선행 정리는 완료됐다. +- 최종 상태는 사용자 로컬 수동 검증 결과를 반영한 뒤 `검증 통과` 또는 `추가 보완 필요`로 갱신한다. diff --git a/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md new file mode 100644 index 000000000..915cb2826 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md @@ -0,0 +1,57 @@ +# SFR-011 노후관로관리 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-011 노후관로관리` 유지보수 라운드의 검증 결과를 기록한다. +- 이번 검증은 팝업 위치/폭 유지보수와 좁은 폭 레이아웃 보정 중심이다. + +## 2. 현재 검증 상태 + +### 2.1 수행 여부 + +- 정적 코드 검토와 타입 검사 시도를 수행했다. +- 자동 브라우저 검증은 수행하지 못했다. + +### 2.2 수행 결과 + +- 정적 구현 확인 + - `agedPipePopup` 최종 크기가 `850 x 600`으로 조정됐다. + - `agedPipePopup`이 `browser-left + alignTop + offsetTop: 200` 기준 배치를 사용하도록 설정됐다. + - 조회/다운로드 버튼이 `시군구`와 같은 수평선 오른쪽으로 정렬되도록 반영됐다. + - 필터 영역 하단 여백이 축소되도록 반영됐다. + - `UiTable` 외곽 마진 제거와 중복 총건수 숨김이 반영됐다. + - 표 영역이 현재 팝업 높이 기준에서 하단 공백이 과도하지 않도록 조정됐다. +- 로컬 라우트 응답 확인 + - `http://127.0.0.1:3002/popup/agedPipePopup?elapsedYears=30&ctpvCd=0&sggCd=0` 응답 `200` + - `http://127.0.0.1:3002/map` 응답 `200` +- 정적 타입 검사 + - 실행 명령: `pnpm --filter sn exec tsc --noEmit --pretty false` + - 결과: 실패 + - 확인 내용 + - 저장소 기존 타입 오류가 다수 존재한다. + - 출력된 기존 오류 경로 + - `app/(map-layout)/map/components/common/index.ts` + - `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` + - `app/html/(map-layout)/map/components/common/index.ts` + - `app/html/(with-base-layout)/layout.tsx` + - `packages/common/business_components/src/ui/chart/*` + - 명령 말미에 `Command "tsc" not found`가 함께 출력돼 현재 실행 환경도 안정적이지 않다. + +### 2.3 미수행 항목 + +- `localhost:3002/map` 실제 브라우저에서 `노후관로 > 목록보기` 팝업 위치 육안 확인 +- 팝업 최소화 후 재오픈 체감 확인 +- 실제 최종 크기(`850 x 600`) 상태에서 행 선택, 페이지 이동, 다운로드 버튼 상호작용 확인 + +## 3. 현재 리스크 + +- 자동 브라우저 캡처용 Playwright 의존성이 현재 세션에 없어 화면 증적을 남기지 못했다. +- 팝업 위치가 사용자 의도와 완전히 일치하는지는 수동 검증 전까지 확정할 수 없다. +- 저장소 기존 타입 오류 때문에 이번 변경의 정적 게이트 통과 여부를 분리 보장하기 어렵다. + +## 4. 후속 검증 권장 항목 + +- `http://127.0.0.1:3002/map`에서 실제 팝업 열기 +- 사용자 스크린샷 기준 위치 체감 확인 +- 최종 크기(`850 x 600`) 상태에서 표 하단 공백 체감 확인 +- 필요 시 문서만 현재 운영값 기준으로 재동기화 diff --git a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md new file mode 100644 index 000000000..fe8b0e784 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md @@ -0,0 +1,66 @@ +# SFR-012-002 하수관망도 업로드 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-012-002 하수관망도 업로드`의 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 현재는 구현 전 단계이므로 FE-only 검증 범위와 BE 연동 후 검증 범위를 먼저 분리한다. + +## 2. 검증 기준 요약 + +- `GIS 관리` popup이 정상적으로 열린다. +- 하수관로만 시설구분 대상으로 노출된다. +- 파일 선택 시 파일명이 표시된다. +- 업로드 성공 시 현황 목록이 즉시 갱신된다. +- 다운로드는 선택 행이 있을 때만 가능 상태가 된다. +- 원본 분리 저장 안내가 popup에서 유지된다. + +## 3. FE-only 검증 계획 + +### 3.1 popup 진입 + +- 우측 기능 버튼 `GIS 관리` 클릭 +- popup open 여부 +- popup 제목, 업로드 영역, 현황 그리드 노출 여부 + +### 3.2 업로드 폼 + +- 하수관로 단일 시설구분이 기본 표시되는지 확인 +- 하수맨홀, 하수처리시설 등 다른 시설구분이 노출되지 않는지 확인 +- 파일 미선택 상태에서 `업로드` 클릭 차단 +- 파일 선택 후 파일명 input 반영 + +### 3.3 업로드 현황 + +- mock 업로드 성공 시 목록 상단 반영 +- 행 선택 시 선택 강조 및 상태 메시지 반영 +- 다운로드 버튼 선택 상태 확인 +- 항목 10건 초과 시 기존 공용 페이지네이션이 노출되는지 확인 +- 첫 화면 기준 5행 수준이 자연스럽게 보이는지 확인 +- `찾기`, `업로드`, `다운로드` 버튼 색과 간격이 화면에 맞는지 확인 + +### 3.4 요청 미리보기 + +- popup을 닫으면 분석도구 메뉴 active가 해제되는지 확인 + +## 4. BE 연동 후 검증 계획 + +- 허용 확장자 검증 +- 최대 파일 크기 검증 +- 시설구분 매핑 검증 +- 중복 파일 검증 +- 업로드 실패 시 원본 보호 +- 다운로드 권한/존재 검증 + +## 5. 차단 사유 분류 기준 + +- `기능 결함` +- `인증 차단` +- `환경 차단` +- `데이터 차단` +- `자동화 한계` + +## 6. 현재 상태 + +- 설계 완료 +- FE-only mock 구현 완료 +- 브라우저 정밀 조작 검증은 일부 미실행 diff --git a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md new file mode 100644 index 000000000..9003fb241 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md @@ -0,0 +1,63 @@ +# SFR-012-002 하수관망도 업로드 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-012-002 하수관망도 업로드`의 검증 결과를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 구현 전 단계의 초기 상태를 기록한다. + +## 2. 현재 검증 상태 + +### 2.1 수행 여부 + +- FE-only mock 구현 기준으로 정적/라우트 검증을 일부 수행했다. + +### 2.2 사유 + +- 실제 백엔드 업로드/다운로드 연결이 아직 없으므로, 이번 결과는 popup 렌더링과 mock 흐름 중심이다. + +### 2.3 수행 결과 + +- 정적 구현 확인 + - `gisManagementPopup` placeholder가 실제 popup page wrapper 구조로 교체됨 + - 시설구분 선택, 파일 선택, mock 업로드 이력, 다운로드 대상 선택 UI 확인 + - 업로드 현황 10건 단위 페이지네이션 연결 확인 + - popup 종료 시 분석도구 active 해제 로직 연결 확인 + - `찾기`, `업로드` 버튼 `조회` 계열 색 반영 + - `다운로드` 버튼 `green` 계열 색 반영 + - 업로드 row 버튼 그룹 정렬/간격 조정 반영 +- 로컬 라우트 응답 확인 + - `http://127.0.0.1:3002/popup/gisManagementPopup` 응답 `200` + - `http://127.0.0.1:3002/map` 응답 `200` +- 응답 HTML 문자열 확인 + - `GIS 업로드` + - `GIS 업로드 현황` + - `시설구분` + - `다운로드` +- 정적 타입 검사 + - `pnpm --filter sn exec tsc --noEmit --pretty false --incremental false` + - 결과: 실패 + - 사유: 기존 저장소 타입 오류 + - `app/(map-layout)/map/components/common/index.ts` + - `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` + - `app/html/(map-layout)/map/components/common/index.ts` + - `app/html/(with-base-layout)/layout.tsx` + - `packages/common/business_components/src/ui/chart/*` + - 이번 `GisManagementPopupContent.tsx` 관련 신규 오류는 출력되지 않았다. + +## 3. 후속 검증 예정 항목 + +- `GIS 관리` popup open +- 시설구분/파일 선택 유효성 +- mock 업로드 이력 갱신 +- 다운로드 대상 선택 +- 10건 초과 페이지 전환 +- popup 종료 후 active 해제 +- 버튼 색/간격 최종 육안 확인 +- BE 연동 후 실제 업로드/다운로드 검증 + +## 4. 현재 리스크 + +- 서버 계약이 확정되기 전까지 FE 검증 항목 일부는 mock 기준에 머문다. +- 실제 파일 선택, 업로드 버튼 클릭, 행 선택 상호작용은 브라우저 수동 확인이 한 번 더 필요하다. +- 저장소 기존 타입 오류 때문에 이번 기능만 분리한 정적 통과 여부를 보장하기 어렵다. +- 시설구분 목록은 아직 실제 시설물 레이어 조회 결과와 자동 동기화되지 않는다. diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md new file mode 100644 index 000000000..2fa3712c3 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md @@ -0,0 +1,241 @@ +# SFR-012-003 하수관로편집 통합 검증 계획 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 검증 계획을 기능당 1개 문서로 누적 관리한다. +- 기존 `001`, `003`, `005`, `007`, `009`, `011` 계획 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 검증 계획 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 검증 기준 요약 + +- 레이어 선택 전 편집 도구 비활성 +- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` 변경분은 저장 전까지 로컬 세션 유지 +- `MOVE` + - preview drag 후 geometry 반영 +- `VERTEX_EDIT` + - vertex marker 표시 + - 양 끝점 이동 가능 + - geometry 반영 +- 저장 시 원본 직접 수정 금지 + +## 3. 라운드별 검증 계획 요약 + +### 3.1 초기/후속 검증 + +- `GIS 편집` 버튼 진입/종료, 메뉴 강조 잔류 재확인 +- 기본 툴바 노출과 상태 메시지 확인 + +### 3.2 세션도구 확장 검증 + +- 7개 편집 레이어 노출 확인 +- 레이어별 도구 활성 규칙 확인 +- `undo`, `reset` 확인 + +### 3.3 실제지도조작 확장 검증 + +- `MOVE`, `VERTEX_EDIT`, `DELETE`가 실제 지도 조작 구조를 받을 수 있는지 코드/브라우저 기준으로 점검 +- 자동 브라우저 drag는 한계가 있어 수동 검증 필요로 기록 + +### 3.4 `MOVE` + +- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적 +- 유지 기준으로 본다 + +### 3.5 `VERTEX_EDIT` + +- 가장 많은 변동이 있었고 현재도 재검증 필요 +- 핵심 검증 기준 + - 선택 직후 vertex marker 표시 + - 양 끝점 이동 가능 + - 이동 후 geometry가 실제로 변경 + +### 3.6 2026-05-15 세션 유지 / `MOVE` / `VERTEX_EDIT` 회귀 검증 + +- 사용자 피드백 기준으로 아래 항목을 우선 재검증한다. + - `ADD`, `MOVE`, `VERTEX_EDIT` 후 세션 기록과 지도 preview가 바로 사라지지 않는지 + - 신규 추가 preview가 레이어별로 다른 색상으로 보여 구분 가능한지 + - 편집 세션 기본 패널이 최근 5건 정도 높이만 보이고, 아래 이력은 패널 내부 스크롤로 바로 확인 가능한지 + - `MOVE` 대상 선택 후 preview drag가 실제로 반응하는지 + - `VERTEX_EDIT` 대상 선택 후 모든 실꼭지점 marker와 midpoint handle이 드러나는지 + - `VERTEX_EDIT` 양 끝점 drag가 실제로 반응하는지 + - `VERTEX_EDIT` midpoint drag 후 새 vertex가 삽입되는지 + - `VERTEX_EDIT` 드래그 후 geometry 변경이 세션 요약과 preview에 반영되는지 + - `하수처리시설`이 point 기준으로 `VERTEX_EDIT` 비대상으로 처리되는지 + +### 3.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 검증 + +- QGIS 비교 기준으로 아래 항목을 별도 확인한다. + - 실꼭지점은 항상 보이되, midpoint는 모든 segment에 상시 표시되지 않고 hover segment 기준으로만 나타나는지 + - endpoint와 중간 vertex가 동일한 주황색 handle 규칙으로 이동되는지 + - midpoint drag가 새 vertex 삽입으로 반영되는지 + - feature 선택이 유지된 상태에서 연속 꼭지점 편집이 가능한지 + - `MOVE` 도구 회귀 없이 preview drag가 기존처럼 유지되는지 + +### 3.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 검증 + +- 편집 세션 고정 패널이 더 이상 지도 위에 남지 않는지 +- GIS 편집 진입 시 `GIS 편집 세션` window popup이 열리는지 +- 팝업에서 변경 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 메인 지도와 동기화되는지 +- 세션 안내 문구 영역이 제거됐는지 +- `추가`, `이동`, `꼭지점편집`, `삭제` 버튼에 `lucide-react` 아이콘이 표시되는지 + +### 3.9 2026-05-21 관로 기본정보 세션 편집 검증 + +- 편집 대상 레이어가 `하수관로`일 때만 편집 상세정보창이 열리는지 +- `MOVE`, `VERTEX_EDIT`, `ADD` change 선택 후 상세정보창 상단 `기본정보` 테이블에서 7개 필드가 직접 수정 가능한지 +- 신규 추가일 때 `경과년도`, `설치일자 확인불가` 같은 조회 전용 표식이 노출되지 않는지 +- 상세정보창 저장이 즉시 DB 저장이 아니라 `basicInfo` 세션 저장으로만 처리되는지 +- 입력 중 스크롤이 상단으로 점프하지 않고 현재 편집 위치를 유지하는지 +- `GIS 편집 세션` 목록에 `기본정보 세션저장` 표식이 나타나는지 +- 최종 저장 payload에 `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`가 함께 들어가는지 +- `DELETE` change나 관로 외 레이어에서는 상세정보 세션 편집창이 열리지 않는지 +- 편집 세션 모드에서는 `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션이 숨겨지는지 +- `MOVE`, `VERTEX_EDIT` 중 비하수관로 클릭 시 선택/줌 이동 없이 차단되고 상태문구만 바뀌는지 +- `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 편집 상세정보창이 열리는지 + +### 3.10 2026-06-26 MOVE 도구 on/off 유지보수 검증 계획 + +- 구현 후 아래 순서로 검증한다. + 1. `GIS편집`을 켜고 `이동` 버튼을 한 번 클릭한다. + 2. 하수관로 A를 선택하고 drag 이동을 완료한다. + 3. 이동 완료 후 툴바의 `이동` 버튼 active 상태가 유지되는지 확인한다. + 4. `이동` 버튼을 다시 누르지 않은 상태에서 하수관로 B를 선택해 이동 interaction이 다시 준비되는지 확인한다. + 5. 하수관로 B 이동 완료 후에도 `이동` active 상태가 유지되는지 확인한다. + 6. `이동` 버튼을 다시 클릭하면 active 상태가 꺼지고, 이후 지도 클릭이 이동 변경분을 만들지 않는지 확인한다. +- 회귀 확인 + - `추가`, `꼭지점편집`, `삭제` 버튼은 기존처럼 하나만 active여야 한다. + - `스냅핑` on/off는 `이동` active 유지와 별개로 유지되어야 한다. + - `닫기`, `저장`, `세션 초기화`, `마지막 변경 취소` 후 이동 도구와 interaction이 남지 않아야 한다. +- 검증 분류 + - 툴바 active 상태와 상태 메시지는 자동 관측 가능하다. + - 실제 canvas drag와 관로별 연속 이동 체감은 수동 브라우저 검증을 기본으로 한다. +## 4. 현재 검증 범위 + +### 4.1 진행 중 + +- `ADD` geometry family별 직접 캡처 +- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 + +### 4.2 이후 범위 + +- 실제 저장 API 연결 이후 통합 저장 검증 +- 원본 보호와 편집본 참조 갱신 검증 + +## 5. 현재 테스트 메모 + +- dev 서버가 켜져 있으면 `next build`는 수행하지 않는다. +- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. +- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증이 핵심이다. + +## 5-1. 브라우저 검증 선행조건 + +- `/map` 진입 가능한 로그인 상태 또는 로컬 임시 로그인 경로가 준비되어 있어야 한다. +- FE dev 서버와 관련 BE API가 모두 응답해야 한다. +- 편집 대상 레이어와 샘플 객체를 재현 가능한 테스트 데이터로 확보해야 한다. +- 인증 또는 외부 연동 문제로 진입이 막히면 `기능 결함`이 아니라 `인증 차단` 또는 `환경 차단`으로 분류한다. +- 로컬 인증 우회 필요 시 `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md`를 참조한다. + +## 5-2. 브라우저 자동검증 관측 포인트 + +| 구분 | 관측 포인트 | 기대 결과 | 검증 방식 | +| --- | --- | --- | --- | +| 공통 | `GIS편집` 버튼 | 편집 모드 진입 시 강조, 종료 시 해제 | 자동 | +| 공통 | 편집 툴바 | 편집 모드에서만 노출 | 자동 | +| 공통 | 레이어 선택 | 미선택 시 도구 비활성, 선택 시 활성 | 자동 | +| 세션 | 변경 목록 | 조작 후 항목 누적 | 자동 | +| 세션 | `undo`, `reset` | 상태 복구 또는 초기화 | 자동 | +| `ADD` | 상태 메시지/신규 항목 | geometry family별 신규 추가 반영 | 자동 중심 | +| `MOVE` | preview label, 목록 요약 | drag 후 geometry 변경 반영 | 자동+수동 | +| `VERTEX_EDIT` | vertex marker 또는 modify handle | 선택 직후 노출, 이동 후 geometry 변경 | 수동 중심 | +| `DELETE` | 임시 삭제 스타일 | 저장 전까지 유지 | 자동+수동 | +| `SAVE` | payload/응답 처리 | 성공 또는 실패 메시지와 세션 상태 반영 | 자동 | + +## 5-3. 차단 사유 분류 기준 + +- `기능 결함`: 기대한 상태 전이 또는 응답 처리가 깨짐 +- `인증 차단`: 로그인, 토큰, 권한 문제로 진입 또는 호출이 막힘 +- `환경 차단`: 서버 미기동, 프록시/네트워크 문제 +- `데이터 차단`: 레이어 메타, 샘플 객체, 테스트 BBOX 부족 +- `자동화 한계`: canvas drag, vertex 조작을 도구가 안정적으로 재현하지 못함 + +## 6. 다음 검증 순서 + +1. `ADD` + - 점형 클릭 추가 + - 선형 연속 클릭 추가 + - 면형 폐합 추가 + - 편집 모드 종료 후에도 세션/preview 유지 확인 +2. `MOVE` + - preview drag 회귀 없는지 확인 + - drag 종료 후 요약/geometry 반영 확인 +3. `VERTEX_EDIT` + - 실꼭지점 marker 표시 + - hover segment midpoint 표시 + - 양 끝점 이동 + - 중간 vertex 이동 + - 드래그 후 geometry/요약 반영 확인 +4. `DELETE` + - 임시 삭제 스타일 유지 + +## 7. 흡수한 기존 문서 + +- `001-qa-SFR-012-003-하수관로편집-검증계획.md` +- `003-qa-SFR-012-003-하수관로편집-후속검증계획.md` +- `005-qa-SFR-012-003-하수관로편집-세션도구확장검증계획.md` +- `007-qa-SFR-012-003-하수관로편집-실제지도조작검증계획.md` +- `009-qa-SFR-012-003-하수관로편집-MOVE실드래그검증계획.md` +- `011-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증계획.md` + +## 8. 2026-07-15 편집 세션 인라인 전환 검증 계획 + +1. 정적 구조 + - GIS 편집 진입 시 팝업 자동 호출이 제거되었는지 확인한다. + - 인라인 세션이 시설물 관리 오른쪽, 검색바와 같은 상단 기준으로 배치되는지 확인한다. + - 편집 도구바가 `top: 84px`로 이동했는지 확인한다. +2. 세션 크기와 스크롤 + - 변경분 0건에서 패널이 빈 상태 내용 높이만 차지하는지 확인한다. + - 변경분 증가 시 패널이 최대 높이까지 늘어나는지 확인한다. + - 최대 높이 이후 변경 목록에 세로 스크롤이 생기고 시설물 접기 토글을 가리지 않는지 확인한다. +3. 팝업 호환 + - 인라인 `팝업` 버튼으로만 기존 팝업이 열리는지 확인한다. + - 팝업 정의의 `460 x 640` 크기와 기존 위치/메시지 계약이 유지되는지 확인한다. +4. 회귀 + - 인라인/팝업에서 변경분 선택, 취소, 되돌리기, 초기화가 동일 편집 상태에 반영되는지 확인한다. + - 타입 검사와 `git diff --check`를 수행하고 선행 오류와 신규 오류를 구분한다. + +## 9. 2026-07-15 사용자 화면 피드백 2차 검증 계획 + +1. 검색바 비가림 + - 인라인 세션 표시 전후 검색바의 시도·시군구·시설물·조회 컨트롤이 모두 노출되는지 확인한다. + - 팝업 뷰 전환 시 검색바가 원래 중앙 위치로 돌아오는지 확인한다. +2. 도구바 종속 배치 + - GIS 편집 도구바가 인라인 세션 카드 바로 아래에서 같은 왼쪽 기준으로 시작하는지 확인한다. + - 세션 변경분 증가 시 도구바가 카드 아래로 내려가고 카드 목록이 최대 높이부터 스크롤되는지 확인한다. +3. 인라인/팝업 상호 배타 전환 + - 인라인 `팝업` 클릭 직후 화면 인라인 세션이 사라지는지 확인한다. + - 팝업의 `화면에서 보기` 클릭 시 팝업이 닫히고 인라인 세션이 같은 변경 상태로 복원되는지 확인한다. + - 팝업 창의 닫기 버튼을 직접 눌러도 300ms 이내 인라인 세션이 복원되는지 확인한다. +4. 회귀 + - 취소·뒤로가기·초기화·변경분 포커스가 전환 전후 동일 상태를 사용하는지 확인한다. + - 다른 `Gis` 사용 화면에서 검색바 기본 중앙 배치가 유지되는지 확인한다. + +## 10. 2026-07-15 표시 영역 기준 최종 검증 계획 + +1. 검색바 고정 + - 편집 모드 진입, 변경분 추가, 팝업 전환 전후 검색바 좌표가 바뀌지 않는지 확인한다. +2. 상단 축약 세션 + - 변경분 0건에서 편집 세션이 검색바와 유사한 높이로 보이는지 확인한다. + - 첫 변경분부터 작업 버튼과 목록이 나타나며 아래 방향으로 커지는지 확인한다. + - 최대 높이 이후 목록에만 세로 스크롤이 생기는지 확인한다. +3. 하단 도구바 + - GIS 편집 도구바가 지도 하단 중앙 표시 영역에 고정되는지 확인한다. + - 세션 확장과 팝업 전환에도 도구바 좌표가 유지되는지 확인한다. +4. 팝업 왕복 + - 팝업 열기 시 인라인 숨김, `화면에서 보기`와 직접 닫기 시 인라인 복원을 재확인한다. + +## 11. 2026-07-15 GIS 편집 토글 종료 회귀 검증 + +1. 변경분이 있는 상태에서 분석도구 `GIS 편집`을 두 번째 클릭한다. +2. 하단 GIS 편집 바와 인라인 편집 세션이 함께 사라지는지 확인한다. +3. 팝업 뷰 상태에서 GIS 편집을 끄면 팝업도 닫히는지 확인한다. +4. GIS 편집을 다시 켜면 기존 변경분이 인라인 세션에 복원되는지 확인한다. diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md new file mode 100644 index 000000000..93697c3fe --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -0,0 +1,761 @@ +# SFR-012-003 하수관로편집 통합 검증 결과 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 검증 결과를 기능당 1개 문서로 누적 관리한다. +- 기존 `002`, `004`, `006`, `008`, `010`, `012` 결과 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 검증 결과 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 수행한 검증 요약 + +### 2.1 초기/후속 검증 + +- `GIS 편집` 모드 진입/종료 +- 메뉴 강조 잔류 재확인 + +### 2.2 세션도구 확장 검증 + +- 7개 편집 레이어 노출 +- 도구 활성 규칙 +- `undo`, `reset` + +### 2.3 실제지도조작 확장 검증 + +- `MOVE`, `VERTEX_EDIT`, `DELETE` 구조 점검 +- 자동 브라우저 drag 한계 확인 + +### 2.4 `MOVE` + +- preview drag 구조와 세션 반영 흐름은 상대적으로 안정적 + +### 2.5 `VERTEX_EDIT` + +- 여러 차례 보강했지만 현재는 재검증 필요 상태 + +### 2.6 2026-05-15 세션 유지 / 상호작용 회귀 대응 점검 + +- 코드 기준 점검 + - `GIS편집` 모드 종료 후에도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 수정했다. + - 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강했다. + - 편집 세션 기본 패널은 최근 5건 정도 높이만 보이도록 고정하고, 아래 이력은 패널 내부 스크롤로 바로 확인하도록 바꿨다. + - 추가 조정으로 세션 패널 높이를 더 줄이고, 하단 안내 문구는 제거했다. + - preview layer는 상호작용 중 redraw되도록 `updateWhileInteracting` 보강이 들어갔다. + - `MOVE`, `VERTEX_EDIT` interaction effect는 `gisEdit` 전체 객체 의존 대신 실제 pending interaction 중심으로 좁혔다. + - 원본 피처 선택 시 layerId 정규화를 추가해 namespace 또는 feature id 형식 차이로 편집 진입이 막히지 않게 했다. + - `하수처리시설(tb_il_swage_prcs_fclt)`은 point 기준으로 정정해 `VERTEX_EDIT` 대상에서 빠지도록 맞췄다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 예시 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx` 타입 변환 경고 + - 공통 chart 컴포넌트 implicit any +- 로컬 라우트 응답 확인 + - `http://127.0.0.1:3002/map` 응답 200 + - `http://127.0.0.1:3002/auth/login` 응답 200 +- 자동 브라우저 검증 + - Playwright headless 실행 시 `spawn EPERM`으로 실패해 실제 canvas drag 자동검증은 수행하지 못했다. + - 따라서 `MOVE`, `VERTEX_EDIT`의 실조작 확인은 수동 브라우저 재검증이 남아 있다. + +### 2.7 2026-05-15 QGIS 유사 `VERTEX_EDIT` 재구현 점검 + +- 코드 기준 점검 + - `VERTEX_EDIT`는 custom vertex handle layer를 유지하되, 실꼭지점 상시 표시와 hover segment midpoint 1건 표시 구조로 단순화했다. + - midpoint 후보는 `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon` 모두 계산 가능하도록 분리했다. + - vertex 근처에서는 midpoint를 숨기고, segment 근처에서만 midpoint를 띄우도록 pixel tolerance를 적용했다. + - endpoint와 일반 vertex는 같은 drag 규칙을 사용하고, midpoint drag는 새 좌표 삽입으로 처리한다. + - `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 유지했고, vertex layer는 별도 레이어로 계속 분리했다. +- 정적 검증 + - `pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 이번 변경분 관련 신규 타입 오류는 정리했고, 저장소 기존 오류만 남았다. + - 남은 기존 오류 예시 + - `components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - 공통 chart 컴포넌트 implicit any +- 로컬 환경 확인 + - `http://127.0.0.1:3002/map` 응답 200 + - `http://127.0.0.1:3002/auth/login` 응답 200 + - `http://127.0.0.1:3002/popup/gisEditSessionPopup` 응답 200 + - 현재 세션에서는 브라우저 전용 자동화 도구를 확보하지 못해 canvas 기반 midpoint hover/drag 실조작은 수동 재검증 필요로 남긴다. + +### 2.8 2026-05-15 편집 세션 팝업 전환 및 도구 아이콘 점검 + +- 코드 기준 점검 + - 지도 좌하단 고정 세션 패널 렌더링은 제거했다. + - `GIS 편집 세션` popup definition, registry, popup content를 추가해 기존 window popup 체계에 맞췄다. + - 메인 `/map`은 GIS 편집 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화하도록 바꿨다. + - 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 메인 지도에 다시 전달하도록 연결했다. + - 세션 패널의 안내 문구 영역은 제거했다. + - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 `lucide-react` 아이콘을 사용하도록 바꿨다. +- 정적 검증 + - `pnpm exec tsc --noEmit --incremental false --pretty false` + - 결과: 여전히 저장소 기존 오류로 실패 + - 기존 오류 범주 + - 기존 공통 타입 불일치 + - 기존 chart implicit any +- 수동 재검증 필요 항목 + - 브라우저 팝업 차단 여부 + - 세션 팝업과 메인 지도 간 메시지 동기화 + - 도구 아이콘 실제 렌더링 + +### 2.9 2026-05-21 관로 기본정보 세션 편집 점검 + +- 코드 기준 점검 + - `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`가 추가돼 geometry change와 관로 기본정보 draft를 함께 담을 수 있게 했다. + - 하수관로 change를 선택하면 편집 전용 상세정보창을 열고, 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 기본정보를 읽어 와 세션 저장할 수 있게 했다. + - 상세정보창 저장은 기존 즉시 `POST /api/v1/fcltppl`이 아니라 `updateChangeBasicInfo`로 change draft만 갱신한다. + - 최종 저장 payload는 `changeId`, `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`를 함께 조립하도록 바뀌었다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - html 라우트 경로 import 누락 + - 공통 chart 컴포넌트 implicit any + - 이번 변경 파일 관련 신규 오류는 별도 출력되지 않았다. +- 수동 재검증 필요 항목 + - 편집 모드에서 실제 하수관로 선택 시 상세정보창이 예상대로 열리는지 + - 기본정보 저장 후 세션 목록 뱃지가 정상 반영되는지 + - 최종 payload 미리보기에 관로 기본정보와 geometry가 함께 담기는지 + - 백엔드 geometry 저장 API 부재 상태에서 사용자가 최종 저장 한계를 어떻게 인지할지 + +### 2.10 2026-05-21 자동저장 루프/선택 제한 보강 점검 + +- 코드 기준 점검 + - 입력값 변경을 `useEffect` 자동 저장으로 처리하던 구조를 제거하고, 실제 `input/select` 변경 이벤트에서만 세션 draft를 갱신하도록 바꿨다. + - 편집 팝업 wrapper를 안정화해 입력 중 remount로 인한 스크롤 상단 점프 가능성을 줄였다. + - 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해 `MOVE`, `VERTEX_EDIT` + 하수관로 레이어 조합일 때 비하수관로 선택을 선택 단계에서 차단하도록 확장했다. + - 비하수관로 차단 시 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다. + - `MOVE`, `VERTEX_EDIT`의 하수관로 선택 시 상세정보창 오픈은 preview 준비 완료 대기 대신 `setTimeout(0)` 기준 즉시 오픈으로 단순화했다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - html 라우트 경로 import 누락 + - 공통 chart 컴포넌트 implicit any +- 수동 재검증 필요 항목 + - 하수관로 편집 팝업의 입력 중 스크롤 위치 유지 + - `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 팝업 오픈 + - 비하수관로 클릭 시 줌 이동 완전 차단 + - `ADD` 미리보기와 `MOVE`, `VERTEX_EDIT` interaction 회귀 여부 + +### 2.11 2026-05-22 최종본/이력 DB 저장 통합 검증 + +- 검증 환경 + - 화면: `http://localhost:3002/map` + - 프론트 프록시 대상: `http://localhost:8883/sewerage-service` + - 백엔드: `sewerage-service` local profile, port `8883` + - DB: `sm_bs_ms.tb_ms_swage_ppl`, `sm_bs_ms.tb_ms_edit_swage_ppl` +- 발견 문제 + - 브라우저에서 `ADD` 저장 요청은 정상 전송됐지만 최초 검증 시 `500 / E999`가 반환됐다. + - DB 직접 확인 결과, 최종본 `tb_ms_swage_ppl`의 다음 fid는 `4132694`였지만 이력 테이블에는 이미 `fid=4132694, edit_sn=0` 이력이 존재했다. + - 기존 BE 채번이 최종본 `MAX(fid)+1`만 사용해 삭제 이력이 남은 fid를 재사용했고, 이력 테이블 복합키 `tb_ms_edit_swage_ppl_pk(fid, edit_sn)`와 충돌했다. +- 조치 내용 + - 신규 fid 채번 기준을 최종본과 이력 테이블을 합친 `MAX(fid::bigint)+1`로 변경했다. + - 브라우저 저장 성공 후 원본 레이어 refresh가 호출되도록 기존 저장 성공 처리 흐름을 유지했다. + - 신규 관로의 시설관리번호와 관로관리번호가 비어 있거나 `NEW-*` 임시값이면 저장 API를 호출하지 않고 상세 입력창을 열도록 FE 검증을 추가했다. +- 브라우저/API 검증 결과 + - `ADD`: 지도에서 신규 관로를 그리고 `저장` 버튼 클릭, 응답 `200`, `editCd=C`, `editSn=0`, 신규 `fid=4132695` + - `MOVE`: 같은 브라우저 세션에서 신규 fid 기준 수정 저장, 응답 `200`, `editCd=U`, `editSn=1` + - `DELETE`: 같은 브라우저 세션에서 신규 fid 기준 삭제 저장, 응답 `200`, `editCd=D`, `editSn=2` + - 번호 미입력 신규 저장: 저장 요청 `0건`, 상태문구 `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.` 노출 확인 +- DB 검증 결과 + - 삭제 후 `tb_ms_swage_ppl`의 테스트 fid 최종본 건수: `0` + - 삭제 전 검증 시 `tb_ms_edit_swage_ppl`의 테스트 fid 이력: `0:C,1:U,2:D` + - 검증 완료 후 테스트 fid `4132695`의 최종본/이력 흔적은 정리했다. + - 중간 자동화 중 잘못 선택된 기존 관로 `fid=2280012`는 직전 이력 기준으로 geometry를 원복했고, 테스트로 생성된 `edit_sn=1` 이력은 삭제했다. +- 정적 검증 + - BE: `.\gradlew.bat compileJava test --tests iipbs.sewerage.service.map.SwagePplEditServiceTest` 성공 + - FE: `pnpm --filter sn exec tsc --noEmit --pretty false`는 저장소 기존 오류로 실패했으며, 이번 GIS 편집 변경 파일 관련 신규 오류는 확인되지 않았다. +- 남은 확인 사항 + - 자동화 좌표 클릭으로 `MOVE` 대상을 고를 때, 새 관로와 기존 관로가 겹치면 기존 관로가 선택될 수 있다. 실제 QA에서는 신규 관로를 기존 관로와 겹치지 않는 위치에 생성하거나 fid 기반 재선택 보조가 필요하다. + - 저장 전 신규 관로의 시설관리번호/관로관리번호 입력 편의성은 별도 화면 검증에서 추가 확인이 필요하다. + +### 2.12 2026-05-26 하수관망도 GIS 편집 UI/기능 상세 재검증 + +#### 2.12.1 검증 목적 + +- 하수관망도 화면의 `GIS 편집` 기능을 실제 사용자 조작 흐름 기준으로 재검증한다. +- `추가`, `이동`, `꼭지점편집`, `삭제`, `저장 검증`, `편집 세션` 흐름을 함께 확인한다. +- 사용자가 제기한 "기능이 이상한 것 같다"는 관찰을 UI/기능/계약/검증 가능성 관점으로 분해한다. +- OpenLayers, ArcGIS, QGIS의 일반적인 GIS 편집 UX와 비교해 개선 방향을 정리한다. + +#### 2.12.2 검증 환경 + +- 작업 루트: `D:\workspace` +- 프론트엔드 앱: `D:\workspace\IIPBS-FRONTEND\frontend\apps\sn` +- 화면 URL: `http://localhost:3002/map` +- 백엔드 URL: `http://localhost:8883/sewerage-service` +- 기준 화면: 하수관망도 +- 기준 편집 대상 레이어: `tb_ms_swage_ppl` +- 기준 브라우저 검증 방식: 로컬 개발 서버에 로그인 후 실제 지도 화면에서 버튼 클릭, 좌표 클릭, 드래그, 더블클릭 수행 +- 병행 QA: `qa` 서브에이전트로 백엔드 테스트, API 검증, 인증 차단 여부 확인 + +#### 2.12.3 검증 전제와 환경 제약 + +- `sn` Next 개발 서버가 `3002` 포트에서 이미 실행 중이었다. +- `AGENTS.md` 기준에 따라 개발 서버가 실행 중인 상태에서는 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`을 실행하지 않았다. +- 로컬 브라우저 직접 검증은 개발 로그인 계정으로 진행했다. +- QA 서브에이전트는 별도 세션에서 `/auth/login` 인증 차단에 걸려 화면 직접 검증을 수행하지 못했다. +- 따라서 본 섹션의 UI 직접 조작 결과는 메인 브라우저 검증 결과를 기준으로 하고, 서브에이전트 결과는 API/BE/정적 검증 보조 증적으로 사용한다. + +#### 2.12.4 브라우저 진입 및 초기 로딩 상태 + +- `/map` 직접 접근 시 로그인 페이지로 이동하는 것을 확인했다. +- 개발 로그인 후 하수관망도 화면에 진입했다. +- 지도, 좌측 시설 트리, 우측 분석 메뉴, 하수관망도 레이어 표시는 확인됐다. +- `GIS 편집` 버튼 클릭 시 지도 상단 중앙에 편집 툴바가 표시됐다. +- 툴바 구성은 `추가`, `이동`, `꼭지점편집`, `삭제`, `저장`으로 확인됐다. +- 편집 진입 직후 `저장` 버튼은 비활성 상태였다. +- 상태 문구는 `하수관로 편집 도구를 선택한 뒤 지도에서 편집 작업을 진행하세요.`로 표시됐다. +- 지도 초기 로딩 중 다음 오류가 관찰됐다. + - `BIZ_TY_CD 데이터 로드 실패` + - `BIZ_SE_CD 데이터 로드 실패` + - 레이어 설정 조회 실패, `500 Internal Server Error` + - 메뉴 wrapper 계열 `AxiosError` +- 위 오류는 GIS 편집 단독 결함으로 단정하지 않았지만, 편집 QA 중 화면 신뢰도와 재현성을 낮추는 환경/연동 리스크로 기록한다. + +#### 2.12.5 `추가` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `GIS 편집` 진입 후 `추가` 선택, 지도 위 여러 지점 클릭, 마지막 지점 더블클릭 | +| 기대 결과 | 신규 하수관로 선형 sketch가 생성되고, 지도 위에서 선택 가능한 preview가 명확히 표시되며, 상세정보 입력 패널과 저장 가능 상태가 연결된다. | +| 실제 결과 | 신규 변경분 `NEW-1`이 생성되고 `저장` 버튼이 활성화됐다. 우측 상세정보 패널이 열렸고 기본정보 입력 영역이 표시됐다. | +| 통과 항목 | 도구 활성화, 선형 입력 완료, 신규 변경분 생성, 상세 패널 오픈, 저장 버튼 활성화 | +| 문제 항목 | 신규 선형이 지도 위에서 충분히 강조되지 않았고, 우측 상세정보 패널이 지도 영역을 크게 가려 생성 위치 확인이 어렵다. | +| 판정 | 부분 통과 | + +- `추가` 도구의 기본 기능은 동작한다. +- 다만 사용자는 "내가 방금 추가한 관로가 어디에 생겼는지"를 지도에서 즉시 확인하기 어렵다. +- 신규 객체는 고대비 선형, 라벨, 선택 테두리, 꼭지점 표시 중 최소 하나 이상의 강한 피드백이 필요하다. +- 상세정보 패널이 우측 지도 영역을 크게 차지하므로 추가 직후 선형 위치와 패널 입력을 동시에 확인하기 어렵다. + +#### 2.12.6 `이동` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | 신규 관로 추가 후 `이동` 선택, 신규 관로가 생성된 것으로 예상되는 위치에서 클릭 및 드래그 | +| 기대 결과 | 이동 대상이 선택되고, 이동용 preview 또는 선택 테두리가 표시되며, 드래그 시 객체 geometry만 이동한다. | +| 실제 결과 | 이동 도구 안내 문구는 표시됐지만, 대상 선택 상태와 드래그 핸들이 명확하지 않았다. 드래그 시 객체 이동보다 지도 pan처럼 보이는 동작이 관찰됐다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | 선택 대상 식별 불명확, 이동 preview 식별 불명확, 드래그 중 지도 이동과 객체 이동이 구분되지 않음 | +| 판정 | 불안정 | + +- `이동`은 GIS 편집에서 사용자가 가장 직관적으로 기대하는 기능 중 하나지만, 현재 화면에서는 "어떤 객체가 선택됐고 무엇을 잡고 움직여야 하는지"가 명확하지 않다. +- 편집 대상이 선택되면 지도 pan보다 객체 이동 interaction이 우선되어야 한다. +- 선택 후 이동 가능한 상태에서는 cursor, 선택 테두리, preview 색상, 상태 문구가 함께 바뀌어야 한다. + +#### 2.12.7 `꼭지점편집` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `꼭지점편집` 선택 후 신규 관로 또는 기존 관로로 보이는 위치를 클릭하고 vertex handle 표시 및 드래그 가능 여부 확인 | +| 기대 결과 | 실꼭지점은 주황색 handle로, 새 점 삽입 후보 midpoint는 흰색 handle로 안정적으로 표시된다. 꼭지점을 드래그하면 선형 geometry가 즉시 갱신된다. | +| 실제 결과 | 안내 문구는 표시됐지만 실제 주황색 꼭지점과 흰색 midpoint handle을 안정적으로 확인하지 못했다. 꼭지점 선택/드래그 가능 상태도 사용자 관점에서 확정하기 어려웠다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | vertex handle 표시 불명확, midpoint 삽입 후보 확인 불가, 드래그 가능 대상 식별 불가 | +| 판정 | 불량 | + +- `꼭지점편집`은 현재 가장 큰 기능 리스크다. +- 기존 문서에서도 `VERTEX_EDIT`는 QGIS 기준 사용자 경험 재정렬과 실조작 수동 재검증이 필요한 항목으로 남아 있었다. +- 이번 재검증에서도 사용자가 기대하는 "꼭지점이 보이고 잡아서 움직인다"는 핵심 피드백이 충분하지 않았다. +- 선형 편집 도구는 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 시각적으로 구분해야 한다. + +#### 2.12.8 `삭제` 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | `삭제` 선택 후 신규 관로 또는 기존 관로로 보이는 위치 클릭 | +| 기대 결과 | 삭제 대상이 선택되고, 즉시 최종 삭제하지 않더라도 pending-delete 스타일이 표시되며, 세션 변경목록에 삭제 변경분이 추가된다. | +| 실제 결과 | 삭제 도구 안내 문구는 표시됐지만, 어떤 객체가 삭제 대상으로 선택됐는지 명확한 지도 피드백을 확인하지 못했다. | +| 통과 항목 | 도구 활성화, 상태 문구 표시 | +| 문제 항목 | 삭제 대상 선택 피드백 부족, pending-delete 스타일 확인 어려움, 되돌리기 흐름 노출 부족 | +| 판정 | 불안정 | + +- 삭제는 저장 전 pending 상태가 반드시 명확해야 한다. +- 현재처럼 선택 피드백이 약하면 사용자는 기존 관로를 잘못 삭제 대상으로 올렸는지 확인하기 어렵다. +- 삭제 대상은 빨간 점선, 반투명 처리, `삭제 예정` 라벨, 세션 목록 강조가 함께 필요하다. + +#### 2.12.9 저장 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 재현 절차 | 신규 관로 `NEW-1` 생성 후 필수값을 충분히 입력하지 않은 상태에서 `저장` 클릭 | +| 기대 결과 | 저장 API 호출 전 필수값 누락을 차단하고, 누락 필드와 입력 위치를 정확히 안내한다. | +| 실제 결과 | 저장 API 호출 전 차단됐고, 상세정보 패널이 열리며 상태 문구가 표시됐다. | +| 통과 항목 | 신규 관로 필수값 누락 시 저장 API 호출 차단 | +| 문제 항목 | 상태 문구와 화면 필드명이 일치하지 않음 | +| 판정 | 부분 통과 | + +- 표시 문구: `신규 하수관로 저장 전 시설관리번호와 관로관리번호를 입력해주세요.` +- 화면 필드명: `시설관리번호`, `기관관리번호` +- `관로관리번호`와 `기관관리번호`가 서로 다른 의미라면 화면에 두 필드를 모두 분명히 노출해야 한다. +- 같은 의미라면 필드명과 검증 문구를 통일해야 한다. +- 이 불일치는 저장 실패 원인을 사용자가 잘못 이해하게 만들 수 있으므로 UI 문구 결함으로 분류한다. + +#### 2.12.10 편집 세션 및 팝업 검증 결과 + +| 항목 | 내용 | +| --- | --- | +| 기대 결과 | GIS 편집 진입 시 편집 세션 목록, 마지막 변경 취소, 세션 초기화, 항목 focus가 사용자가 확인 가능한 위치에 표시된다. | +| 실제 결과 | 메인 지도 툴바는 표시됐지만, 세션 팝업 또는 변경목록이 사용자에게 명확히 노출되는지 확인이 어려웠다. | +| 판정 | 불안정 | + +- 기존 구현 기록상 GIS 편집 세션은 popup 기반으로 전환되어 있다. +- 실제 사용자는 브라우저 팝업 차단, 포커스 이동, 별도 창 위치 문제로 세션 상태를 놓칠 수 있다. +- 편집 기능은 저장 전 변경목록 확인이 중요하므로, 팝업이 열리지 않거나 가려져도 메인 화면 안에서 최소 변경 건수와 마지막 변경 요약은 확인 가능해야 한다. + +#### 2.12.11 서브에이전트 QA 결과 요약 + +- 코드 변경 없이 읽기, 브라우저, API, 테스트 중심으로 검증했다. +- BE `.\gradlew.bat compileJava`: 통과 +- BE `.\gradlew.bat test`: 통과, `BUILD SUCCESSFUL` +- FE 빌드: 미실행 + - `sn` 개발 서버가 `3002`에서 실행 중이므로 사용자 확인 없이 빌드하지 않았다. +- FE `pnpm --filter sn exec tsc --noEmit --pretty false`: 실패 + - 기존 공통/앱 TypeScript 오류 다수 확인 + - 이번 GIS 편집 단독 신규 오류로 단정하지 않음 +- 서브에이전트 브라우저 검증: `/auth/login` 인증 차단으로 GIS 화면 직접 검증 불가 +- API 검증: + - `GET /sewerage-service/api/v1/map/gisEdit/swagePpl/meta?lyrGrp=SN_NETWORK`: 통과 + - 편집 대상: `tb_ms_swage_ppl` + - 지원 편집: `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` + - 저장 테이블: `sm_bs_ms.tb_ms_swage_ppl` + - 이력 테이블: `sm_bs_ms.tb_ms_edit_swage_ppl` + - 빈 변경분 저장 요청: `400 GIS_EDIT_EMPTY_CHANGES` 정상 + - 비관로 레이어 저장 요청: `400 GIS_EDIT_PIPE_ONLY` 정상 + - `GET /gisEdit/swagePpl/histories?fid=1`: `200 OK` +- 서브에이전트 증적: + - `D:\workspace\qa-evidence\sfr-012-003-auth-blocker.png` + - `D:\workspace\qa-evidence\sfr-012-003-session-popup-direct.png` + +#### 2.12.12 외부 GIS 편집 UX 비교 + +| 비교 대상 | 참고 기준 | 현재 화면과의 차이 | +| --- | --- | --- | +| OpenLayers Draw | `Draw` interaction은 sketch 시작, 진행, 완료 이벤트를 별도 interaction으로 관리한다. | 현재 `추가`는 완료 후 신규 sketch의 시각 강조가 약하다. | +| OpenLayers Modify | `Modify` interaction은 vertex 조작을 명확한 handle 기반으로 제공한다. | 현재 `꼭지점편집`은 실꼭지점과 midpoint handle이 안정적으로 보이지 않는다. | +| OpenLayers Translate | `Translate` interaction은 선택 feature 이동을 지도 pan과 분리해 처리한다. | 현재 `이동`은 드래그 시 객체 이동과 지도 pan이 사용자에게 구분되지 않는다. | +| ArcGIS Map Viewer / Web Editor | 편집 대상 선택, vertex 편집, 저장 전 확인 흐름을 사용자에게 명확히 제공한다. | 현재 세션 변경목록과 pending 상태 노출이 약하다. | +| QGIS Vertex Tool | 실꼭지점, segment, midpoint 삽입 후보를 시각적으로 구분한다. | 현재 `VERTEX_EDIT`는 QGIS 수준의 조작 피드백에 도달하지 못했다. | + +참고 링크: + +- OpenLayers Draw: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Draw-Draw.html +- OpenLayers Modify: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Modify-Modify.html +- OpenLayers Translate: https://openlayers.org/en/latest/apidoc/module-ol_interaction_Translate-Translate.html +- ArcGIS Online Map Viewer editing: https://doc.arcgis.com/en/arcgis-online/manage-data/edit-features-mv.htm +- ArcGIS Web Editor vertex editing: https://doc.arcgis.com/en/web-editor/latest/create-and-edit/edit-vertices.htm +- QGIS geometry editing: https://docs.qgis.org/latest/en/docs/user_manual/working_with_vector/editing_geometry_attributes.html + +#### 2.12.13 결함 및 개선 백로그 + +| 우선순위 | 구분 | 내용 | 개선 방향 | +| --- | --- | --- | --- | +| P1 | 기능/UX | `이동`, `꼭지점편집`, `삭제`에서 편집 대상 선택 피드백이 불충분하다. | 선택 feature를 고대비 overlay로 표시하고, 선택/drag 가능 상태를 cursor와 상태 문구로 함께 제공한다. | +| P1 | 기능 | `꼭지점편집`의 vertex handle과 midpoint handle이 안정적으로 확인되지 않는다. | 실꼭지점, 선택 꼭지점, hover segment, midpoint 삽입 후보를 별도 style로 분리한다. | +| P1 | 기능 | `이동` 드래그가 지도 pan과 구분되지 않는다. | 선택 feature drag 중 지도 pan을 일시 제한하고, 이동 preview source를 명확히 분리한다. | +| P1 | 기능/세션 | 세션 변경목록과 pending 상태가 사용자에게 명확하지 않다. | 팝업 실패 fallback 또는 메인 지도 내 축약 세션 패널을 제공한다. | +| P2 | UI | `추가` 후 신규 선형이 지도에서 충분히 강조되지 않는다. | 신규 객체 label, 선택 테두리, vertex 표시, 자동 focus를 보강한다. | +| P2 | UI | 상세정보 패널이 지도 확인을 방해한다. | 패널 폭/위치/접힘 상태를 조정하고, 선택 객체 focus와 겹치지 않게 한다. | +| P2 | 문구 | 저장 검증 문구의 `관로관리번호`와 화면 필드 `기관관리번호`가 불일치한다. | 백엔드 계약 기준으로 필드명을 통일하고 누락 필드를 행 단위로 강조한다. | +| P2 | 연동 | 지도 초기 로딩 중 코드/레이어 설정 조회 오류가 발생한다. | GIS 편집 결함과 분리해 공통 코드/레이어 설정 API 오류를 먼저 정리한다. | +| P3 | QA | QA 서브에이전트 세션에서 인증 차단으로 화면 검증이 막힌다. | QA용 로컬 로그인 절차, 테스트 계정, 토큰 주입 절차를 문서화한다. | + +#### 2.12.14 종합 판정 + +- 백엔드 컴파일, 테스트, 기본 GIS 편집 API 계약은 정상 범위로 확인됐다. +- 프론트엔드 정적 검증은 기존 TypeScript 오류로 실패 상태이며, 이번 검증만으로 GIS 편집 단독 신규 오류라고 단정하지 않는다. +- 실제 사용자 기준의 GIS 편집 UI는 `추가`만 부분 통과이고, `이동`, `꼭지점편집`, `삭제`는 선택 피드백과 조작 피드백이 부족해 개선 필요로 판정한다. +- 특히 `VERTEX_EDIT`는 기존 검증 문서에서 이미 진행 중/리스크로 분류된 항목이며, 이번 재검증에서도 동일하게 핵심 개선 대상으로 확인됐다. +- 다음 개선 순서는 `선택 피드백`, `vertex handle`, `이동 drag와 지도 pan 분리`, `세션 변경목록 노출`, `저장 검증 문구 정리` 순서가 적절하다. + +### 2.13 2026-05-26 Playwright 기반 GIS 편집 실조작 재검증 + +#### 2.13.1 검증 범위와 경로 + +- 검증 방식: Playwright MCP 브라우저 자동화 기반 실제 화면 조작 +- 로그인 경로: `http://localhost:3002/auth/login` +- 로그인 계정: `CLEANWATER` +- 지도 경로: `http://localhost:3002/map` +- 백엔드 경로: `http://localhost:8883/sewerage-service` +- 세션 팝업 경로: `http://localhost:3002/popup/gisEditSessionPopup` +- 테스트 관리번호: + - 1차: `QA-PW-20260526-001`, `clientTempId=NEW-1` + - 2차: `QA-PW-20260526-002`, `clientTempId=NEW-2` +- 최종 정리 상태: 저장 API가 두 차례 모두 `500 E999`로 실패해 `fid/editSn`은 발급되지 않았고, DB 삭제 저장은 수행할 대상이 없었다. 미저장 로컬 세션은 `마지막 변경 취소`로 `총 0건`까지 정리했다. + +#### 2.13.2 기능별 실조작 결과 + +| 기능 | 재현 절차 | 기대 결과 | 실제 결과 | 심각도 | 개선 제안 | +| --- | --- | --- | --- | --- | --- | +| 로그인/진입 | `/auth/login`에서 `CLEANWATER` 로그인 후 `/map` 진입, `GIS 편집` 클릭 | 지도와 편집 툴바, 세션 팝업이 표시된다. | 로그인 성공, `/map` 진입 성공, `GIS 편집` 클릭 시 툴바와 세션 팝업 탭 표시 확인 | 통과 | QA 계정 기반 재현 절차를 검증 문서에 유지한다. | +| 추가 | 빈 위치에 선형 클릭-더블클릭, 상세정보에 `QA-PW-20260526-001/002` 입력, `저장` 클릭 | 신규 관로가 저장되고 응답에서 신규 `fid`, `editSn=0`, `editCd=C`가 확인된다. | preview, 상세 패널, 세션 목록, 저장 버튼 활성화는 확인됐으나 저장 API가 2회 모두 `500 E999`로 실패했다. `fid/editSn` 미발급 | P1 | BE는 `E999/ERROR` 대신 원인 코드와 메시지를 반환해야 한다. FE는 저장 실패 시 사용자가 조치 가능한 필드/원인을 표시해야 한다. | +| 추가 payload | 상세정보의 `시설관리번호`, `기관관리번호`에 테스트 번호 입력 후 세션 payload 확인 | 입력한 관리번호가 저장 payload의 필수 식별 필드에 일관되게 반영된다. | `fclt_mng_no`, `inst_mng_no`에는 값이 들어가지만 `fcltMngNo`, `mngInstCd`는 빈 문자열로 전송됐다. | P1 | 신규 관로 입력값 병합 시 빈 문자열을 유효값으로 우선하지 말고 snake/camel 필드를 동일 값으로 정규화해야 한다. | +| 이동 | 저장 실패 후 남은 `NEW-2` preview 기준 `이동` 선택, 대상 클릭 후 drag | 대상 관로가 선택되고 지도 pan이 아니라 관로 preview가 이동한다. | 상태문구는 이동 모드로 전환됐지만 대상 선택/drag 후 세션 변경이나 화면상 선택 피드백을 확인하지 못했다. | P2 | 미저장 ADD preview도 선택 가능 여부를 명확히 하거나, 지원하지 않는다면 상태문구로 제한을 안내한다. | +| 꼭지점편집 | `꼭지점편집` 선택 후 `NEW-2` preview 클릭 및 vertex drag 시도 | 주황색 실꼭지점과 흰색 midpoint가 보이고 vertex/midpoint 조작이 가능하다. | 상태문구는 vertex 안내로 전환됐지만 주황색/흰색 handle과 실제 drag 반영을 확인하지 못했다. | P2 | 선택 성공/실패 피드백, vertex handle 표시 조건, midpoint 삽입 가능 범위를 화면에서 명확히 표시한다. | +| 삭제 | `삭제` 선택 후 `NEW-2` preview 클릭 | 삭제 예정 스타일, 세션 변경목록, 되돌리기 가능 상태가 표시된다. | 저장된 fid가 없어 최종 삭제 저장은 불가했다. 미저장 preview 클릭 후 삭제 변경 전환은 확인되지 않았고, 세션에는 ADD 1건만 남았다. | P2 | 미저장 ADD 객체 삭제는 세션 목록의 `마지막 변경 취소`와 같은 명시적 UI로 안내하거나 지도 클릭 삭제를 지원한다. | +| 취소/롤백 | 세션 팝업에서 `마지막 변경 취소` 클릭 | 마지막 변경분이 제거되고 저장할 변경분이 없어야 한다. | `총 0건`, `저장할 편집 변경분이 없습니다.` 확인. 지도 상태문구도 `가장 최근 편집 변경분을 세션에서 제거했습니다.`로 표시 | 통과 | 최종 저장 실패 후 사용자가 쉽게 정리할 수 있도록 이 동작을 메인 툴바에도 노출하는 방안을 검토한다. | + +#### 2.13.3 네트워크/API 응답 + +| 순번 | 요청 | 테스트 관리번호 | 응답 | 기록 | +| --- | --- | --- | --- | --- | +| 147 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-001` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-1`, `fid/editSn` 미발급 | +| 149 | `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` | `QA-PW-20260526-002` | `500 Internal Server Error`, body `{"message":"ERROR","status":500,"code":"E999"}` | `clientTempId=NEW-2`, `fid/editSn` 미발급 | + +- 2차 저장 요청 주요 payload: + - `layerId`: `tb_ms_swage_ppl` + - `saveTargetType`: `FINAL` + - `view.srs`: `EPSG:5179` + - `editType`: `ADD` + - `geometry.type`: `LineString` + - `coordinates`: `[[1068626.8294507712,1774978.3276935548],[1068741.356641528,1774930.6080307395],[1068855.8838322847,1774978.3276935548]]` + - `attributes.fclt_mng_no`: `QA-PW-20260526-002` + - `attributes.inst_mng_no`: `QA-PW-20260526-002` + - `attributes.fcltMngNo`: 빈 문자열 + - `attributes.mngInstCd`: 빈 문자열 + +#### 2.13.4 콘솔 오류 + +- `GET http://localhost:3002/server/cms-service/api/v1/menu-roles/SN` -> `401 Unauthorized` +- `POST http://localhost:3002/sewerage-service/api/v1/map/gisEditSessions/save` -> `500 Internal Server Error` +- `GIS 편집 저장 실패: Error: ERROR` +- `AxiosError` + +#### 2.13.5 화면 캡처 + +- `D:\workspace\qa-evidence\sfr-012-003-login-map-loaded.png` +- `D:\workspace\qa-evidence\sfr-012-003-add-before-save.png` +- `D:\workspace\qa-evidence\sfr-012-003-session-add-failed-payload.png` +- `D:\workspace\qa-evidence\sfr-012-003-add-retry-before-save.png` +- `D:\workspace\qa-evidence\sfr-012-003-move-unsaved-no-selection.png` +- `D:\workspace\qa-evidence\sfr-012-003-vertex-unsaved-no-handles.png` +- `D:\workspace\qa-evidence\sfr-012-003-session-undo-clean.png` + +#### 2.13.6 현재 판정 + +- `ADD`는 화면 조작, 상세정보 입력, 세션 반영, 저장 버튼 활성화까지는 통과했지만 최종 저장 API가 `500 E999`로 실패해 통합 검증은 실패로 판정한다. +- `MOVE`, `VERTEX_EDIT`, `DELETE`는 저장된 테스트 fid가 없어 최종본 기준 검증을 수행하지 못했다. 미저장 preview 기준으로는 선택/drag/삭제 피드백이 명확하지 않아 보류 및 UX 결함으로 분류한다. +- 테스트 관리번호 `QA-PW-20260526-001/002`는 저장 실패로 `fid/editSn`이 생성되지 않았다. 최종본 삭제 저장 정리 대상은 없었고, FE 로컬 세션은 `총 0건`으로 정리했다. +- `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn`은 실행하지 않았다. `sn` dev server가 `3002`에서 실행 중이므로 AGENTS.md 기준에 따라 빌드 검증은 제외했다. + +### 2.14 2026-06-08 스냅핑 토글 / Delete 키 꼭지점 삭제 코드 반영 점검 + +- 코드 기준 점검 + - `GisEditToolbar`에 `스냅핑` on/off 버튼이 추가됐고, 편집 세션 팝업 헤더에도 `스냅핑 ON/OFF` 상태가 보이도록 반영했다. + - `useGisEdit`는 `isSnapEnabled` 상태를 관리하고, `MOVE`, `VERTEX_EDIT` 안내 문구에 스냅핑 상태를 함께 반영한다. + - 초기 반영 직후 `ADD`에는 스냅핑이 연결되지 않아 사용자가 스냅핑 ON 상태로 신규 관로를 그려도 기존 관로에 붙지 않는 문제가 있었다. + - 후속 수정으로 `page.tsx`의 `ADD`, `MOVE`, `VERTEX_EDIT` interaction 진입 시 현재 보이는 하수관로 원본/preview feature를 묶은 `Snap` interaction을 함께 추가한다. + - `VERTEX_EDIT` 중 vertex handle을 클릭하면 선택 상태가 유지되고, 키보드 `Delete`로 해당 꼭지점을 삭제한 뒤 `updateChangeGeometry(..., { keepVertexEditActive: true })` 흐름으로 세션/preview를 다시 갱신한다. + - midpoint는 클릭 시 안내 문구만 내리고 Delete 삭제 대상에서는 제외한다. + - 꼭지점 삭제 유틸은 선형 최소 2점, 폴리곤 최소 3점을 보장하도록 방어 로직이 들어갔다. +- 정적 검증 + - 실행 명령 + - `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` + - 결과 + - 이번 변경분 관련 신규 타입 오류는 정리했다. + - 아래 기존 저장소 오류는 그대로 남아 있다. + - `app/(map-layout)/map/components/common/index.ts` 타입 불일치 + - `app/(map-layout)/map/page.tsx` 기존 null 가능성 경고 1건 + - `app/html/(with-base-layout)/layout.tsx` import 누락 + - 공통 chart 컴포넌트 implicit any +- 수동 재검증 필요 항목 + - 스냅핑 `ON/OFF`에 따라 `ADD`, `MOVE`, `VERTEX_EDIT` 체감이 실제로 달라지는지 + - 주황색 실꼭지점 클릭 후 `Delete` 입력이 정상 동작하는지 + - 2점만 남은 선형에서 Delete가 차단되는지 + - 겹친 handle 묶음에서 Delete가 기대한 범위만 제거하는지 + +### 2.15 2026-06-08 QGIS 기준 1차 스냅 재구현 점검 + +- 코드 기준 점검 + - 기존 “현재 지도에 이미 올라온 feature를 임시 수집”하던 방식 대신, 현재 화면 BBOX 기준 `tb_ms_swage_ppl` WFS를 직접 다시 읽어오는 전용 pipe snap source를 추가했다. + - `ADD`는 `Draw + Snap(source)` 구조로 다시 연결했고, `VERTEX_EDIT`도 custom vertex handle 위에 같은 pipe snap source를 사용하도록 조정했다. + - 후속 보정으로 `MOVE`도 같은 pipe snap source를 우선 사용하도록 확장했다. + - `VERTEX_EDIT`에서 기존 관로를 편집하는 경우에는 자기 자신 fid를 snap source에서 제외해 원본 자기 geometry에 다시 달라붙는 현상을 줄이도록 보강했다. + - `moveend` 시 pipe snap source를 재조회해 현재 보이는 원본 하수관로를 기준으로 스냅 상태를 갱신한다. +- 정적 검증 + - 실행 명령 + - `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` + - 결과 + - 이번 1차 재구현으로 인한 신규 타입 오류는 정리했다. + - 남은 오류는 기존 저장소 오류와 기존 `page.tsx` null 가능성 1건이다. +- 수동 재검증 필요 항목 + - 기존 오수관 끝점/선분 근처에서 `ADD` 시작점과 끝점이 실제로 붙는지 + - 기존 오수관 꼭지점 근처에서 `VERTEX_EDIT` 주황색 실꼭지점이 실제로 붙는지 + - 기존 오수관 끝점/선분 근처에서 `MOVE` 결과 geometry가 실제로 붙는지 + - 지도 이동 후 다른 위치에서도 snap source가 새로 갱신되어 계속 붙는지 + +### 2.16 2026-06-08 MOVE grip 기준 재보정 및 입력정보 조회 오류 점검 + +- 코드 기준 점검 + - `MOVE`는 이제 관로 선택 순간 클릭 위치에서 가장 가까운 관로 꼭지점 meta를 `move grip`으로 기억한다. + - 드래그 시작/중/종료 시 원본 geometry snapshot을 다시 만들고, `move grip`이 하수관로 원본 snap source에 붙도록 전체 geometry를 재정렬한다. + - 따라서 `MOVE`도 `ADD`, `VERTEX_EDIT`처럼 “사용자가 조작하는 한 점이 스냅 기준”이 되도록 맞췄다. + - 관로 상세 팝업의 입력정보 조회는 `fcltMngNo`, `mngInstCd`를 `encodeURIComponent(...)` 처리하고, `NEW-*` 임시값 조회를 차단하도록 보강했다. +- 정적 검증 + - 실행 명령 + - `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` + - 결과 + - 이번 보정으로 인한 신규 타입 오류는 정리했다. + - 저장소 기존 오류만 남아 있다. +- 수동 재검증 필요 항목 + - 관로 끝점 근처를 잡고 `MOVE`할 때 grip 기준으로 자연스럽게 붙는지 + - 관로 중간을 잡고 `MOVE`했을 때도 가장 가까운 관로 꼭지점을 기준으로 결과가 일관적인지 + - 이동 중 관로 상세 팝업이 열려도 `fetchPipeInputInfo` 500이 재발하지 않는지 + +### 2.17 2026-06-26 MOVE 도구 on/off 요구사항 분석 결과 + +- 문서/코드 대조 결과 + - 툴바는 이미 같은 `이동` 버튼을 다시 누르면 `deactivateTool()`로 꺼지는 구조다. + - 현재 “한 번 이동하면 이동이 꺼지는” 동작은 사용자가 버튼을 다시 눌러서가 아니라 `MOVE` `translateend` 이후 `updateChangeGeometry()` 기본 경로가 `activeTool`을 `null`로 비우기 때문에 발생한다. + - `VERTEX_EDIT`는 `keepVertexEditActive` 옵션으로 연속 편집을 유지하는 특수 경로가 있으나, `MOVE`는 같은 유지 옵션이 없다. +- 이번 라운드 결과 + - `useGisEdit.updateChangeGeometry()`에 `keepMoveToolActive` 옵션을 추가했다. + - `MOVE` `translateend` 호출에서 `{ keepMoveToolActive: true }`를 넘겨 이동 완료 후에도 `activeTool="MOVE"`가 유지되도록 했다. + - `pendingInteraction`은 이동 완료 후 비워 다음 관로 클릭이 새 이동 대상으로 처리되게 했다. +- 정적 검증 + - 실행 명령: `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` + - 결과: 실패. 저장소 기존 오류(`components/common/index.ts`, `floodHistoryTypes.ts`, `page.tsx` 기존 null 가능성, html route, chart props implicit any`)에서 중단됐다. + - 이번 변경 파일인 `useGisEdit.ts` 관련 신규 오류는 출력되지 않았다. +- 후속 검증 필요 + - 관로 A 이동 후 이동 버튼 active 유지 여부 + - 관로 B 연속 이동 가능 여부 + - 이동 버튼 재클릭 off 여부 + - `추가`/`꼭지점편집`/`삭제`/`스냅핑`/`닫기`/`저장` 회귀 여부 + +## 3. 현재 검증 상태 + +### 3.1 통과 + +- `GIS 편집` 모드 진입/종료 +- 레이어별 도구 활성 규칙 +- 세션 `undo`, `reset` +- `MOVE`의 기본 preview drag +- `스냅핑` 토글 상태의 툴바/세션 스냅샷 반영 +- `VERTEX_EDIT` 실꼭지점 선택 후 `Delete` 키 처리 코드 경로 +- `ADD`, `VERTEX_EDIT`의 하수관로 전용 WFS snap source 연결 +- `MOVE`의 grip vertex 기준 snap 보정 코드 경로 +- 하수관로 `ADD/MOVE/DELETE` 최종본/이력 DB 저장 + +### 3.2 진행 중 + +- `ADD` geometry family별 직접 캡처 +- 2026-05-26 Playwright 재검증 기준 `ADD` 최종 저장 API `500 E999` 원인 분석 및 재검증 +- `VERTEX_EDIT`의 QGIS 기준 사용자 경험 재정렬 +- `ADD`, `VERTEX_EDIT`의 실제 QGIS 체감 스냅 수동 재검증 +- `MOVE`, `VERTEX_EDIT` 실조작 수동 재검증 +- `MOVE` grip 기준 snap 체감 수동 재검증 +- `스냅핑 ON/OFF` 체감과 실제 snap 대상 범위 수동 재검증 +- `Delete` 꼭지점 삭제 후 남은 선형 연결 상태 수동 재검증 +- 2026-05-26 재검증에서 확인된 `이동`, `꼭지점편집`, `삭제` 선택 피드백/조작 피드백 개선 + +### 3.3 보류 + +- 저장 성공 후 GeoServer/GWC 캐시 무효화 운영 방식 검증 +- `ADD` 저장 실패로 인한 신규 fid 기반 `MOVE/VERTEX_EDIT/DELETE/삭제 저장 정리` 재검증 +- QA 서브에이전트 또는 독립 검증자가 사용할 수 있는 로컬 로그인/인증 절차 문서화 + +## 4. 보류/리스크 + +- 2026-05-26 Playwright 재검증에서 신규 관로 저장 API가 `500 E999`로 실패해 최종본 기준 편집 시나리오가 차단됐다. +- 신규 관로 상세정보 저장 payload에서 `fclt_mng_no`/`inst_mng_no`는 입력값이 들어가지만 `fcltMngNo`/`mngInstCd`는 빈 문자열로 남아 필수 식별자 정합성 리스크가 있다. +- dev 서버가 켜져 있으면 `next build`를 함께 돌리기 어렵다. +- `next lint`는 환경 이슈로 안정적인 회귀 수단이 되지 못하고 있다. +- 지도 캔버스 기반 drag/vertex 테스트는 수동 브라우저 검증 의존도가 높다. +- `VERTEX_EDIT`는 하수관로 `LineString`/`MultiLineString` 처리와 vertex marker UX를 다시 고정해야 한다. +- 현재 저장소의 기존 TypeScript 오류 때문에 변경분만 분리한 정적 통과 여부를 보장하기 어렵다. +- 기존 관로와 겹친 위치에서 자동 클릭 기반 `MOVE/DELETE` 검증을 수행하면 의도하지 않은 기존 관로가 선택될 수 있다. +- `GIS 편집 세션`이 popup에 의존하면 팝업 차단, 포커스 이동, 별도 창 위치 문제로 사용자가 변경목록을 놓칠 수 있다. +- 저장 검증 메시지와 화면 필드명이 불일치하면 신규 관로 저장 실패 원인을 사용자가 잘못 이해할 수 있다. +- 지도 초기 로딩 오류가 남아 있으면 GIS 편집 결함과 공통 지도/코드/레이어 설정 오류를 분리하기 어렵다. +- 스냅핑은 현재 source에 로드된 하수관로 원본/preview만 대상으로 하므로, 화면 밖 객체나 미로딩 객체까지는 맞추지 못할 수 있다. +- 겹친 handle을 하나의 묶음으로 보여주는 현재 구조상, 같은 좌표를 공유하는 raw vertex는 Delete 시 함께 제거될 수 있다. + +## 4-1. 관측 증적 요약 + +- 자동으로 확인된 항목 + - `CLEANWATER` 계정 로그인 후 `/map` 진입 + - `GIS편집` 모드 진입/종료 + - 신규 관로 `ADD` preview 생성, 상세정보 입력, 세션 목록 반영 + - 레이어별 도구 활성 규칙 + - 세션 `undo`, `reset` + - `MOVE`의 기본 preview drag 준비 구조 +- 수동 또는 후속 확인이 필요한 항목 + - `ADD` 저장 API `500 E999` 원인 확인 + - 실제 canvas drag 정확도 + - `VERTEX_EDIT`의 vertex marker 노출과 꼭지점 직접 이동 + - 저장 API 연결 후 응답 처리와 원본 보호 +- 간접 관측 표식 + - 상태 메시지 + - 변경 목록 + - preview label + - 임시 삭제 스타일 + +## 4-2. 차단 사유 분류 + +- `자동화 한계` + - 지도 캔버스 기반 drag/vertex 조작은 자동 브라우저 재현 안정성이 낮다. + - 이번 세션에서는 Playwright 또는 브라우저 플러그인 기반 canvas 자동화 경로를 확보하지 못했다. +- `인증 차단` + - 실제 브라우저 통합 검증은 로그인/세션 준비 여부에 따라 막힐 수 있다. +- `환경 차단` + - dev 서버 실행 중 build 병행이 어렵고, lint도 안정적 회귀 수단이 아니다. +- `기능 결함 또는 미완료` + - `ADD` 최종 저장이 `500 E999`로 실패해 신규 fid 기반 후속 검증이 차단된다. + - `VERTEX_EDIT`는 아직 요구사항 기준으로 다시 고정이 필요하다. + +## 5. 흡수한 기존 문서 + +- `002-qa-SFR-012-003-하수관로편집-검증결과.md` +- `004-qa-SFR-012-003-하수관로편집-후속검증결과.md` +- `006-qa-SFR-012-003-하수관로편집-세션도구확장검증결과.md` +- `008-qa-SFR-012-003-하수관로편집-실제지도조작검증결과.md` +- `010-qa-SFR-012-003-하수관로편집-MOVE실드래그검증결과.md` +- `012-qa-SFR-012-003-하수관로편집-VERTEX_EDIT실조작검증결과.md` +### 2.18 2026-06-26 관로 입력정보 조회 오류 방어 결과 + +- 확인된 오류 + - `GisEditPipeSessionPopup.useEffect.fetchPipeInputInfo`에서 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}` 실패 응답이 `buildRequestError()`를 거쳐 `Error: ERROR`로 콘솔 출력됐다. +- 조치 결과 + - 프론트는 잘못된 상세 식별값 조회를 차단하고, 선택적 조회 실패를 콘솔 오류로 출력하지 않도록 수정했다. + - 백엔드는 관로 상세 보조속성/관리기관명 조회 실패를 빈 보조정보로 fallback하여 상세 API가 가능한 한 200 응답을 유지하도록 보강했다. +- 정적 검증 + - FE TypeScript 명령은 기존 저장소 오류로 실패했다. 이번 수정 파일인 `GisEditPipeSessionPopup.tsx` 관련 신규 오류는 출력되지 않았다. + - BE `./gradlew.bat compileJava`는 통과했다. + - FE/BE `git diff --check`는 CRLF 경고만 출력했고 whitespace 오류는 없었다. +- 남은 수동 검증 + - 브라우저에서 MOVE 후 상세 팝업 진입 시 Console Error 오버레이가 뜨지 않는지 확인이 필요하다. + - Network 탭에서 `fcltppl` 조회가 실패해도 팝업 입력 흐름이 유지되는지 확인이 필요하다. + +### 2.19 2026-06-29 임시 관로 스냅 및 세션 버튼 재정렬 점검 + +- 코드 기준 점검 + - `page.tsx`의 pipe snap source는 이제 원본 하수관로 WFS만 담지 않고, 현재 세션의 `ADD`/`MOVE`/`VERTEX_EDIT` preview 관로도 함께 합친다. + - 기존 수정 관로의 원본 fid는 WFS snap source에서 제외해, 이미 옮겨진 preview 관로가 수정 전 원본 geometry에 다시 붙지 않도록 조정했다. + - `GIS 편집 세션` popup은 `취소`, `뒤로가기`, `초기화` 3버튼으로 바뀌었고, `취소`는 현재 focus된 change 제거, `뒤로가기`는 기존 마지막 변경 취소, `초기화`는 전체 세션 비우기 역할로 분리됐다. + - 현재 상세 패널이 열린 change를 `취소`로 제거하면 우측 상세 패널도 닫히도록 방어 로직을 추가했다. +- 정적 검증 + - 실행 명령: `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` + - 결과: 실패. 저장소 기존 오류(`components/common/index.ts`, `floodHistoryTypes.ts`, `page.tsx` 기존 null 가능성, html route, chart implicit any`)만 남았고, 이번 변경 파일 신규 오류는 출력되지 않았다. +- 남은 수동 검증 + - 신규 관로 A를 추가한 직후 신규 관로 B를 추가하거나 이동/꼭지점편집할 때 A preview에 실제로 스냅되는지 확인이 필요하다. + - `취소` 버튼이 현재 선택 change만 제거하고, `뒤로가기`는 가장 최근 change를 제거하는지 popup과 지도 focus 기준으로 확인이 필요하다. + - `취소`로 현재 상세 패널 대상 change를 제거했을 때 우측 패널이 자연스럽게 닫히는지 확인이 필요하다. + +### 2.20 2026-07-15 편집 세션 인라인 전환 검증 결과 + +- 통과 + - `git diff --check`: 통과 + - 레이아웃/팝업 계약 정적 검사: 통과 + - 인라인 위치 `left-[278px] top-4`, 최대 높이 `max-h-[calc(50dvh-82px)]` 확인 + - 도구바 위치 `top-[84px]`, 최대 너비 `760px` 확인 + - 변경 목록 `overflow-y-auto` 확인 + - 기존 팝업 정의 `width: 460`, `height: 640` 유지 확인 + - 변경 파일 기준 TypeScript 신규 오류는 확인되지 않았다. +- 전체 TypeScript 검사 + - 명령: `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` + - 결과: 실패. 이번 변경과 무관한 기존 선행 오류 9건(`components/common/index.ts`, `floodHistoryTypes.ts`, `map/page.tsx:4666`, html route, 공통 chart implicit any)이 남아 있다. +- 브라우저 확인 + - 개발 서버는 기동됐으나 `/map` 접근이 `/auth/login`으로 리다이렉트되어 로그인 정보 없이 실제 지도 조작 검증은 수행하지 않았다. + - 인증 우회나 임의 계정 사용은 하지 않았다. +- 후속 수동 검증 + - 로그인 세션에서 GIS 편집 진입 시 새 창이 자동 생성되지 않고 인라인 세션이 표시되는지 확인한다. + - 실제 변경분을 여러 건 추가해 패널 성장과 목록 스크롤, 시설물 접기 토글 비가림을 확인한다. + - `팝업` 버튼으로 기존 크기/위치의 팝업이 열리고 인라인과 상태가 동기화되는지 확인한다. + +### 2.21 2026-07-15 사용자 화면 피드백 2차 반영 결과 + +- 정적 검증 통과 + - 인라인 표시 조건과 팝업 표시 조건이 `gisEditSessionView`로 상호 배타 처리되는 것을 확인했다. + - 검색바 이동 클래스 `ml-[clamp(160px,16vw,220px)]`와 공통 `Gis.searchBarClassName` 기본값을 확인했다. + - embedded 도구바가 인라인 세션 다음 형제로 렌더링되는 것을 확인했다. + - 팝업 `화면에서 보기` 버튼, `GIS_EDIT_SESSION_SHOW_INLINE` 송수신, 팝업 직접 닫기 시 인라인 복원 처리를 확인했다. + - 기존 팝업 정의 `width: 460`, `height: 640` 유지 및 `git diff --check` 통과. +- TypeScript + - 전체 검사 결과는 기존 선행 오류 9건으로 실패했으며 이번 2차 변경 파일의 신규 오류는 출력되지 않았다. + - 기존 오류 위치 중 `map/page.tsx` 행 번호만 코드 추가로 `4677`로 이동했다. +- 자동 브라우저 제한 + - 로그인된 지도 탭이 없어 실제 화면 조작 검증은 수행하지 못했다. + - 사용자 제공 화면으로 배치 결함을 확인했고, 9절의 검색바 비가림·도구바 종속 배치·양방향 전환은 로그인 환경에서 후속 확인이 필요하다. + +### 2.22 2026-07-15 표시 영역 기준 최종 보정 결과 + +- 정적 검증 통과 + - 검색바 이동용 prop과 클래스가 제거되고 공통 `Gis.js` diff가 없는 것을 확인했다. + - 인라인 세션 위치 `left-[258px] top-4`, 너비 `360px`를 확인했다. + - 변경분 0건 축약 조건과 작업 버튼/빈 목록 숨김 처리를 확인했다. + - 도구바 위치 `bottom-12 left-1/2`, 최대 너비 `960px`를 확인했다. + - 팝업 상호 전환 계약과 기존 `460 x 640` 크기는 유지된다. + - `git diff --check` 통과. +- TypeScript + - 전체 검사는 기존 선행 오류 9건으로 실패했으며 이번 최종 위치 보정의 신규 오류는 출력되지 않았다. +- 수동 확인 대기 + - 로그인된 지도 화면에서 파란 표시 영역과 실제 좌표가 일치하는지 확인이 필요하다. + +### 2.23 2026-07-15 인라인 편집 세션 너비 보정 결과 + +- 사용자 화면으로 `360px` 세션의 검색바 돌출을 확인했다. +- 인라인 세션 계약을 `left-[258px] top-4 w-[220px]`로 변경했다. +- 검색바 이동 코드가 없고 하단 도구바 `bottom-12`가 유지되는 것을 정적으로 확인했다. +- `git diff --check` 통과. 로그인 화면에서 검색바 비가림 해소 최종 확인이 필요하다. + +### 2.24 2026-07-15 인라인 세션 260px 보정 결과 + +- 인라인 세션 계약을 `left-[258px] top-4 w-[260px]`로 조정했다. +- 검색바 이동 코드가 없고 하단 도구바 위치가 유지되는 것을 확인했다. +- `git diff --check` 통과. + +### 2.25 2026-07-15 축약 세션 높이·너비 조정 결과 + +- 인라인 세션 위치/크기 계약 `left-[258px] top-4 w-[265px]`를 확인했다. +- 변경분 0건 축약 조건에만 `h-[58px]`가 적용되는 것을 확인했다. +- 하단 도구바와 검색바 위치가 변경되지 않았고 `git diff --check`가 통과했다. + +### 2.26 2026-07-15 편집 세션 상단 정렬 보정 결과 + +- 인라인 세션 위치 계약 `left-[258px] top-[26px] w-[265px]`를 확인했다. +- 축약 높이 `58px`와 하단 도구바 위치가 유지되는 것을 확인했다. +- `git diff --check` 통과. + +### 2.27 2026-07-15 변경 카드 표시 보정 결과 + +- 제목과 선택 배지에 한 줄 표시 계약이 적용된 것을 확인했다. +- 선택 카드에서 정보저장 배지가 동시에 표시되지 않는 것을 확인했다. +- 추가·이동·꼭지점편집·삭제·상세정보수정별 축약 안내 문구를 확인했다. +- `git diff --check` 통과. +- 전체 TypeScript 검사는 기존 선행 오류 9건만 출력됐고 이번 변경 파일의 신규 오류는 없었다. + +### 2.28 2026-07-15 한 줄 안내 문구 보정 결과 + +- 다섯 편집 유형의 안내가 다음 행동을 설명하는 짧은 문구로 변경된 것을 확인했다. +- 카드 하단 `truncate text-[11px]` 계약과 제목/선택 배지 한 줄 표시가 유지된다. +- `git diff --check` 통과. + +### 2.29 2026-07-15 사용자 수동 검증 완료 + +- 사용자가 로그인된 실제 지도 화면에서 이번 유지보수 범위의 수동 검증 완료를 확인했다. +- 확인 범위 + - 검색바 위치 유지 및 인라인 편집 세션 비가림 해소 + - 축약 세션 위치·높이·너비와 변경분 증가 시 확장/스크롤 + - 하단 중앙 GIS 편집 도구바 배치 + - 인라인·윈도우 팝업 상호 전환 + - 변경 카드 제목/선택 표시 한 줄 노출 및 안내 문구 가독성 +- 결론: SFR-012-003 프론트 유지보수 수동 검증 완료. + +### 2.30 2026-07-15 GIS 편집 토글 종료 보정 결과 + +- 인라인 세션 표시와 팝업 sync가 `gisEdit.isEditMode`를 기준으로 동작하는 것을 정적으로 확인했다. +- 편집 종료 시 팝업 닫기, 재진입 시 inline 복원 처리를 확인했다. +- 변경분 배열을 비우지 않아 재진입 복원 계약이 유지된다. +- `git diff --check` 통과. 실제 지도에서 11절 시나리오 수동 검증이 필요하다. + +### 2.31 2026-07-15 GIS 편집 토글 종료 사용자 수동 검증 완료 + +- 사용자가 실제 지도에서 GIS 편집 두 번째 클릭 시 하단 도구바와 인라인 세션이 함께 사라지는 것을 확인했다. +- 다시 GIS 편집 진입 시 기존 변경분과 인라인 세션이 복원되는 것을 확인했다. +- 결론: 추가 회귀 보정까지 사용자 수동 검증 완료. diff --git a/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md new file mode 100644 index 000000000..a66ce8e0e --- /dev/null +++ b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md @@ -0,0 +1,56 @@ +# 정보검색-하수도정비기본계획보고서 통합검증계획 + +## 1. 문서 목적 + +- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 검증 계획을 기록한다. + +## 2. 검증 범위 + +- report 탭 추가 팝업 제거 여부 +- 조회조건 순서 및 필터 동작 +- 탭 전환 시 자동조회 동작 +- 파일명 셀 직접 다운로드 동작 +- 보고서 행 클릭 제한 동작 +- 공통 select 포커스 표시 잘림 여부 +- 기존 `dc-service` 호출 유지 여부 + +## 3. 검증 기준 + +1. `정보검색` 팝업에서 `report` 탭 선택 시 추가 윈도우 팝업이 열리지 않아야 한다. +2. 조회조건이 `시도 > 시군구 > 승인년도 > 구분 > 보고서 구분` 순서로 보여야 한다. +3. 조회조건이 한 줄 우선 정렬로 보이고 불필요한 하단 공백이 없어야 한다. +4. `기본 정보`, `유지관리 정보`, `하수도 정비 기본계획 보고서` 탭 전환 시 각 탭의 기본조회가 자동 실행되어야 한다. +5. 파일이 있는 행의 `파일명`만 링크 스타일을 가져야 한다. +6. 파일명 클릭 시 별도 파일목록 팝업 없이 다운로드가 시작되어야 한다. +7. 파일명 외 다른 셀 클릭 시 추가 동작이 없어야 한다. +8. `시도`, `시군구`, `승인년도`, `구분`, `보고서 구분` select 포커스 표시가 잘리지 않아야 한다. +9. Network에서 `dc-service`의 `clsf`, `file`, `fileDownload` 호출이 유지되어야 한다. + +## 4. 라운드별 검증 계획 + +### 4.1 정적 검증 + +- 수정 파일 import/상태 흐름 재검토 +- 기존 popup 호출 제거 여부 검색 +- `UiTable` 옵션 추가가 다른 화면 기본동작을 깨지 않는지 코드 기준 확인 + +### 4.2 명령 검증 + +- `pnpm --filter sn lint` + - 목적: 타입/구문/기본 lint 문제 확인 + +### 4.3 수동 브라우저 검증 + +- 분석도구 `정보검색` 클릭 +- `기본 정보`, `유지관리 정보`, `하수도 정비 기본계획 보고서` 순서로 탭 전환 +- 각 탭 진입 시 버튼 없이 기본조회 자동 실행 확인 +- 보고서 탭 조건 변경 후 조회 +- 파일명 클릭 다운로드 확인 +- 파일명 외 셀 클릭 무반응 확인 +- 기본정보/유지관리/보고서 탭의 select 포커스 잘림 여부 확인 +- 하수처리시설 상세의 `조회` 버튼으로 여는 `reportTab` 팝업 회귀 확인 + +## 5. 차단 사유 분류 기준 + +- 코드 문제: 타입 오류, 런타임 오류, 잘못된 자동조회/필터/다운로드 동작 +- 환경 문제: pnpm 설치 정책, lockfile 설정, TTY 제약, 개발 서버 충돌 diff --git a/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md new file mode 100644 index 000000000..4debf96f4 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md @@ -0,0 +1,37 @@ +# 정보검색-하수도정비기본계획보고서 통합검증결과 + +## 1. 문서 목적 + +- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 검증 수행 결과를 기록한다. + +## 2. 수행한 검증 + +### 2.1 정적 확인 + +- `report` 탭 자동 팝업 오픈 제거 반영 여부 확인 +- `reportFiles` 팝업 호출 제거 반영 여부 확인 +- 파일명 직접 다운로드 로직 연결 여부 확인 +- 탭 전환 시 자동조회 흐름 추가 여부 확인 +- `UiTable`의 `clickableHeadIds` 옵션이 미지정 화면에는 영향이 없는 구조인지 확인 +- 공통 `SelectBox` 포커스 보정이 style 변경 수준인지 확인 + +### 2.2 명령 실행 + +- 2026-06-30: `pnpm --filter sn lint` + - 1차 결과: 샌드박스 임시 파일 권한 오류로 실패 + - 2차 결과: `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`로 실패 + - 3차 결과: `CI=true` 재시도 후 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`로 실패 + +## 3. 현재 검증 상태 + +- 코드 반영: 완료 +- 정적 구조 확인: 완료 +- 자동 lint 검증: 환경 문제로 미완료 +- 브라우저 수동 검증: 미실행 + +## 4. 보류/리스크 + +1. 현재 워크스페이스 pnpm 환경이 lockfile 설정 불일치로 자동 lint를 진행하지 못한다. +2. 브라우저 수동 검증이 남아 있어 탭별 자동조회 체감, select 포커스 표시, 여백 보정 체감은 아직 확인하지 못했다. +3. 다건 파일 대표 다운로드 기준은 현재 첫 번째 파일 기준이다. +4. `reportFiles` 팝업 관련 미사용 코드 정리는 후속 정리 대상으로 남긴다. diff --git a/apps/sn/docs/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md b/apps/sn/docs/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md new file mode 100644 index 000000000..03072e28a --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md @@ -0,0 +1,275 @@ +# SFR-010-010 지반침하정보 작업카드 + +## 1. 작업 개요 + +- 작업 ID: SFR-010-010-FE +- 작업명: 하수관망도 지반침하정보 프론트엔드 유지보수 +- 상태: 완료 +- 작성일: 2026-06-02 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `apps/sn` 지도 화면에서 지반침하 레이어 체크/해제, 목록 팝업, 우측 상세 패널, 반경 적용, 관리자 동기화 버튼 노출 제어, BE API 연동 준비 +- 근거 문서: + - `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/001-de-SFR-010-010-지반침하정보-설계.md` + - `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보_요구사항명세서_v1_1.docx` + - `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보-화면구성시나리오_v1_1.md` + - `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보_업무검토시안_v1_1.html` + - `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보_화면설계서.png` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: + - `map/page.tsx`는 노후관로, 유하방향표출, GIS편집 이력이 있어 충돌 주의가 필요하다. + - FE 문서 루트 전체는 문서 재정리 흔적이 있으므로 기존 경로/파일명은 유지한다. + +## 1-2. 최신 해석 메모 + +- 현재 `apps/sn`에는 레이어 on/off, 목록 팝업, 지도 포인트 선택, 사고별 우측 상세 탭, 반경 원과 반경 내 관로 표시가 구현되어 있다. +- 이번 작업은 신규 화면 구축이 아니라 반경 값 라벨, 트리 심볼, 빈 지도 클릭 선택 해제, 목록 사고번호 첫 열을 보완하는 현행 유지보수다. +- 목록 팝업은 `SinkholePopupPage`, 우측 상세와 선택 오버레이는 `map/page.tsx`의 기존 상태 흐름을 유지해 확장한다. +- 목록의 `acdntNo`와 상세/반경의 `radiusMeters`가 이미 제공되므로 백엔드 계약은 변경하지 않는다. + +## 2. 작업 목적 + +- 하수관망도 레이어 트리에서 `지반침하정보`를 실제 기능으로 연결한다. +- 사용자는 목록 팝업에서 사고 이력을 조회하고, 행 클릭 또는 지도 포인트 클릭으로 상세정보와 반경 내 하수관로를 확인할 수 있어야 한다. +- 관리자만 수동 동기화 버튼을 볼 수 있어야 하고, 일반 사용자는 조회/다운로드 중심 UX만 가져야 한다. +- 실제 구현 전 문서 단계에서 FE 상태관리, 재사용 컴포넌트, API 연결 지점, 검증 포인트를 먼저 고정한다. + +## 3. 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 근거 | +| --- | --- | --- | --- | +| 메인 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 예정 | 레이어 트리, 지도 포인트, 반경 원, 상세 패널 상태 연결 | +| 신규 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/*` | 예정 | 목록 팝업, 상세 패널, 반경 패널, API 훅 분리 | +| 팝업 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 예정 | 목록 팝업을 기존 popup 체계로 열 경우 필요 | +| 팝업 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 예정 | popup key와 화면 등록 | +| 팝업 UI | `FE_APP_ROOT/src/features/windowPopup/ui/*` | 예정 | `지반침하발생현황` popup content | +| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 현재 계획은 `apps/sn` 내부 재사용 우선 | + +## 3-1. 쓰기 범위와 충돌 주의 + +| 구분 | 경로 | 담당 역할 | 처리 기준 | +| --- | --- | --- | --- | +| 쓰기 허용 | `apps/sn/**` | FE | 신규 지도 기능과 문서 작성 | +| 쓰기 허용 | `packages/common/business_components/**` | FE | 꼭 필요한 경우만 최소 범위 수정 | +| 쓰기 금지 | `packages/common/business_components` 외 공통 패키지 | FE | 원인과 영향 범위만 기록 | +| 쓰기 금지 | 타 앱 `apps/*` | FE | 보고만 수행 | + +- 충돌 주의 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` + - `FE_APP_ROOT/src/features/windowPopup/registry.tsx` + +## 4. 화면 흐름 + +- 진입 경로: `하수관망도 > 지반침하정보` +- 사용자 조작 + - 레이어 트리에서 `지반침하정보` 체크 + - 목록 아이콘 클릭 + - 목록 팝업에서 조건 입력 후 조회 + - 좌표보정상태 조건 선택 + - 목록 행 클릭 또는 지도 포인트 클릭 + - 좌측 하단 반경 입력 후 적용 + - 관리자일 경우 동기화 클릭 +- 정상 흐름 + - 지도에 지반침하 포인트 표시 + - 우측 상세 패널 노출 + - 반경 원과 반경 내 관로 목록 갱신 + - 목록 다운로드 가능 +- 오류 흐름 + - 날짜 미입력 + - `2016-01-01` 이전 날짜 입력 + - 좌표 없음 + - 상세조회 실패 + - 반경 내 관로 없음 + - 동기화 실패 + +## 5. API 연동 + +| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | +| --- | --- | --- | --- | --- | --- | +| 지도 포인트 조회 | `GET` | `/api/v1/sinkholes/geometry` | 기간/행정구역/limit | GeoJSON `FeatureCollection` | 현재 BE 선반영 | +| 목록 조회 | `GET` | `/api/v1/sinkholes` | 기간, 시도, 시군구, 복구상태, 좌표보정상태, page, size | `items[]`, `total` | 구현 확인 | +| 상세 조회 | `GET` | `/api/v1/sinkholes/{acdntNo}` | 사고번호 | 상세 DTO | 구현 확인 | +| 반경 내 관로 조회 | `GET` | `/api/v1/sinkholes/{acdntNo}/nearbyPipes` | 사고번호, 반경 | 관로 목록 | 구현 확인 | +| 동기화 | `POST` | `/api/v1/sinkholes/sync` | 관리자 요청 body | 처리 결과 | 구현 확인 | +| 다운로드 | `GET` | `/api/v1/sinkholes` | 현재 조회조건 + `includeCoordStatus=false` | FE xlsx 생성 | 목록 재조회 기반 | + +- FE 메모 + - 좌표 보정과 반경 내 관로 거리 계산은 프론트에서 하지 않는다. + - 목록 행의 좌표를 바로 믿지 않고 상세 API 응답으로 다시 기준을 맞춘다. + - 화면에 필요한 일부 필드는 원천 부재 가능성이 있으므로 `null -> "-"` 렌더링 정책이 필요하다. + +## 6. 영향 범위 + +- `apps/sn` 화면 영향 + - 레이어 트리 + - 지도 포인트/반경 원 + - 중앙 목록 팝업 + - 우측 상세 패널 + - 좌측 하단 반경 패널 +- 공통 패키지 영향 + - 현재 계획상 없음 + - 지도 포인트 레이어/클러스터 공통 확장이 꼭 필요하면 별도 승인 후 검토 +- 백엔드 API 영향 + - 지반침하 신규 목록/상세/동기화/다운로드 계약 + +## 6-1. 멀티에이전트 작업 분리 + +- DE 산출물 + - 공통 설계 문서 + - API/DTO/DB 영향 초안 +- FE 산출물 + - 지도 상태관리 구조 + - 목록 팝업 / 상세 패널 설계 + - API 호출 연결 계획 +- BE 산출물 + - 지반침하 API 세트 확장 + - 좌표 캐시 / 동기화 구조 + - 반경 내 관로 조회 +- QA 산출물 + - 정상/예외/권한/환경 검증 시나리오 + +## 7. 검증 방법 + +- 빌드 명령 + - 개발 서버 미실행 확인 후 `pnpm --filter sn run build` + - 필요 시 `pnpm turbo run build --filter=sn` +- 브라우저 URL + - `/map` +- 사용자 조작 + - 지반침하 레이어 체크/해제 + - 목록 아이콘 클릭 + - 조건 조회 + - 목록 행 클릭 + - 지도 포인트 클릭 + - 반경 변경 + - 관리자 동기화 +- 기대 결과 + - 목록/상세/반경/다운로드가 요구사항과 일치 + - 좌표 없음/조회 결과 없음/반경 내 관로 없음이 각각 다른 안내로 표시 + +## 7-1. 하네스 관측 포인트 + +- 레이어 체크박스 상태 +- 목록 팝업 열림 여부 +- 우측 상세 패널 노출 여부 +- 반경 값과 적용 메시지 +- 지도 중심 이동 여부 +- 포인트와 반경 원 노출 여부 +- 관리자 버튼 노출 여부 + +## 8. 롤백 방법 + +- `지반침하정보` 관련 신규 컴포넌트 제거 +- 레이어 트리와 팝업 등록 분기 제거 +- `map/page.tsx`의 지반침하 상태관리 제거 +- 문서는 설계 단계 기준으로 유지하고 코드만 롤백 + +## 9. 예상 위험 + +- `map/page.tsx`에 또 하나의 지도 도메인 로직이 들어가면서 책임이 더 커질 수 있다. +- 목록 팝업을 `window popup`으로 할지 인라인 모달로 할지 구현 방식이 달라질 수 있다. +- 원천 데이터에 없는 상세 필드를 FE가 어떻게 보여줄지 합의가 필요하다. +- 좌표 없는 사고건을 지도와 목록에서 다르게 다뤄야 해 예외 분기가 많다. + +## 10. 완료 기준 + +- `지반침하정보` 체크 시 포인트 표출 가능 +- 목록 아이콘 클릭 시 조회 팝업 오픈 가능 +- 목록 조회/행 클릭/지도 포인트 클릭 흐름이 연결됨 +- 반경 적용 시 반경 원과 반경 내 관로 목록 재조회 가능 +- 관리자 전용 동기화 버튼이 권한 기준으로 분기됨 +- BE 계약 문서와 FE 작업카드가 같은 필드 기준을 사용함 + +## 11. 구현 전 승인 필요 항목 + +- 화면 원본이 없는 필드의 FE 표시 방식 +- 목록 팝업을 기존 popup 창으로 열지, `/map` 내부 모달로 열지 +- 최근 동기화 일시 위치와 노출 범위 +- 좌표 캐시를 메모리 캐시로만 둘지 DB 영속화까지 포함할지 + +## 11-2. 2026-07-02 유지보수 분석: 사고번호 기반 좌표 원천화 전환 + +- 현재 FE는 이미 `지반침하정보` 레이어 on/off, 별도 윈도우 팝업 기반 목록, 우측 상세 패널, 반경 적용, 우측 상세 패널 공통 탭까지 구현돼 있다. +- 따라서 기존 작업카드의 `실구현 없음` 전제는 현행 코드와 맞지 않으며, 이번 유지보수의 FE 역할은 신규 화면 설계보다 “현행 BE 계약 변경 시 FE 회귀 범위를 좁게 유지하는 것”에 가깝다. +- 현행 FE가 소비하는 핵심 계약은 아래와 같다. + - 목록/상세 응답의 `coordStatus`, `geocodeStatus`, `latitude`, `longitude`, `x`, `y` + - 반경 조회 응답의 `items[]` + - 동기화 완료 후 `SINKHOLE_SYNC_COMPLETED_MESSAGE` payload +- 이번 사용자 요구처럼 좌표 원천을 주소 geocoding에서 사고번호 기반 JIS 조회 + 원천 DB update로 바꾸더라도, BE가 위 응답 필드를 그대로 유지하면 FE 코드는 1차 구현에서 변경하지 않아도 된다. +- FE 후속 변경이 필요한 경우는 아래로 한정한다. + - `coordStatus` 상태값이 추가되거나 의미가 바뀌는 경우 + - 관리자용 동기화 버튼/최근 동기화 요약이 “JIS backfill 결과” 중심 문구로 재정의되는 경우 + - 상세 실패 메시지를 주소 geocoding 실패가 아니라 사고번호 기반 조회 실패 문구로 바꾸는 경우 +- 결론적으로 이번 라운드의 FE 기본 입장은 `계약 유지 시 무코드 대응`, `표시 문구/운영성만 후속 점검`이다. +## 12. 2026-07-13 우측 상세 탭 유지보수 구현 범위 + +- `map/page.tsx`의 기존 공통 우측 탭을 확장해 지반침하·관로·처리시설·처리구역을 유형별 색상과 명칭으로 구분한다. +- 지반침하는 사고번호별 다중 탭, 관로는 전역 단일 탭으로 관리한다. +- 지반침하 반경 내 관로에서 연 경우에만 `관로(관리번호)` 표시명 옆에 부모 지반침하로 돌아가는 소형 화살표를 노출한다. +- 탭 이름은 `지반침하(사고번호)`, `관로(관리번호)`, `처리시설(시설명)`, `처리구역(구역명)` 형식을 사용한다. +- 기존 응답과 GeoServer 피처 속성으로 구현 가능하므로 BE API·DTO·DB 변경은 하지 않는다. +- 완료 기준은 변경 파일 타입 오류 0건, Next 컴파일 성공, 부모/단일 탭 상태 전이 정적 확인이며 실브라우저 상호작용은 로그인 가능한 통합 환경에서 후속 확인한다. + +## 13. 처리시설·처리구역 탭 정책 후속 검토 + +- 현재 처리시설·처리구역 탭은 `feature:{layerType}:{featureId}` ID를 사용해 피처별로 여러 개 열릴 수 있다. +- 후속 결정 후보는 `현행 다중 탭 유지`와 `처리시설 1개 + 처리구역 1개 유형별 singleton`이다. +- singleton을 선택하면 동일 유형의 다음 선택은 새 탭을 추가하지 않고 기존 탭의 표시명, feature payload, 활성 시각을 최신 대상으로 교체한다. +- 이 정책은 아직 미확정이며 현재 코드 변경 대상이 아니다. + +## 14. 2026-07-14 현행 유지보수 설계 범위 + +### 14.1 구현 원칙 + +- 현행 지도/우측 탭/API 흐름을 유지하고 네 요구사항에 필요한 최소 분기만 추가한다. +- 지도 반경 라벨은 원 상단 Point 피처로 추가해 원·중심점과 같은 생명주기로 관리한다. +- 트리 심볼은 기존 파란 지반침하 자산을 사용하고 공통 트리 영향은 기본 숨김인 선택 prop으로 격리한다. +- 빈 지도 클릭은 현재 선택 지반침하와 종속 관로 강조만 정리하며 다른 탭과 GIS 편집 상태는 보존한다. +- 목록 팝업은 사고번호를 첫 데이터 열로 두고 화면 NO 열은 표시하지 않는다. + +### 14.2 예상 코드 대상 + +- `app/(map-layout)/map/page.tsx` +- `app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx` +- `packages/common/business_components/src/ui/map/Gis.js` +- `packages/common/business_components/src/ui/map/GisTree.js` +- `packages/common/business_components/src/ui/map/GisSelect.js` +- `packages/common/business_components/src/ui/map/lib/gis.select.js` + +### 14.3 제외 범위 + +- BE API·DTO·Repository·DB 변경 +- 엑셀 다운로드 열 순서 변경 +- 기존 탭 최대 개수, 처리시설·처리구역 탭 정책 변경 + +### 14.4 완료 기준 + +- 구현·정적 검사·빌드 후 로그인 가능한 `/map`에서 반경 라벨, 트리 심볼, 빈 클릭 해제, 목록 첫 열을 수동 확인한다. +- 구현 결과는 통합구현기록과 통합검증결과, 2026-07-14 공통 작업기록에 반영했다. +- 사용자가 통합 화면에서 반경 라벨, 트리 심볼, 빈 지도 클릭 해제, 사고번호 첫 열 및 화면 NO 열 제거를 모두 확인했으며 최종 완료로 판정했다. + +### 14.5 실제 구현 파일 + +- `app/(map-layout)/map/page.tsx` +- `app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx` +- `packages/common/business_components/src/ui/map/Gis.js` +- `packages/common/business_components/src/ui/map/GisTree.js` +- `packages/common/business_components/src/ui/map/GisSelect.js` +- `packages/common/business_components/src/ui/map/lib/gis.select.js` + +- BE API·DTO·Repository·DB는 변경하지 않았다. + +## 15. 2026-07-15 관로 탭 누적 정책 후속 변경 + +- 기존의 관로 전역 단일 탭 정책을 진입 경로별 정책으로 변경한다. +- 지도에서 직접 선택한 일반 관로는 관로 식별값별 탭으로 누적한다. +- 지반침하 상세의 반경 내 관로에서 선택한 관로는 부모 지반침하가 있는 단일 슬롯을 공유하며, 다음 관로 선택 시 같은 탭의 상세를 교체한다. +- 부모가 있는 관로 탭은 활성 여부와 무관하게 `←` 버튼을 항상 표시해 일반 관로와 구분하고 지반침하 상세로 복귀한다. +- 공용 우측 탭은 최대 5개 FIFO를 적용하되, 제거 대상이 부모 지반침하이면 그 부모에서 열린 연계 관로도 함께 제거한다. 연계 관로만 제거될 때는 부모 지반침하를 유지한다. +- 변경 범위는 `app/(map-layout)/map/page.tsx`이며 BE API·DTO·DB 계약은 변경하지 않는다. diff --git a/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md new file mode 100644 index 000000000..ca84bc821 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md @@ -0,0 +1,175 @@ +# SFR-011-007 통수위험분석 작업카드 + +## 1. 작업 개요 + +- 작업 ID: `SFR-011-007-FE` +- 작업명: 통수위험분석 프론트엔드 구현 계획 +- 상태: 완료 +- 작성일: 2026-05-18 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `apps/sn` 지도 화면에서 통수위험 진입, 결과 조회 popup, 지도보기, 설정 현황, staged 유출관로 선택, 저장 payload 준비까지 +- 근거 문서 + - `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md` + - `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_요구사항명세서_v1_2.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_화면_v1.1.html` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_통수위험분석결과 조회_화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통수위험분석_통수위험분석결과 지도보기_화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-007-통수위험분석/통합위험분석_화면구성시나리오_v1.1.md` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: `map/page.tsx`, `AnalysisPopupMenu.tsx`, `windowPopup/**`는 최근 지도형 기능 작업 이력이 있어 충돌 주의가 필요하다. + +## 2. 작업 목적 + +- 기존 하수관망도 지도 화면에 `통수위험` 기능을 추가해 사용자가 결과 조회, 지도보기, 설정 현황 조회, 유출관로 설정 편집을 한 흐름 안에서 수행할 수 있게 한다. +- 프론트는 백엔드 연결 전 단계에서 popup UI, 동적 관로 컬럼, mock 지도 강조, staged 설정 목록, 저장 payload preview까지 먼저 구현한다. +- 실제 산정 로직, geometry 데이터, 다운로드 파일, 저장 API는 BE 범위로 분리하고 FE는 그 계약을 맞출 준비 상태까지 수행한다. + +## 2-1. 최근 진행 갱신 + +- 2026-05-18 + - `통수위험` 메뉴의 placeholder popup을 실제 `WaterRiskPopupContent`로 교체했다. + - popup 안에서 결과 조회, 설정 현황, staged 유출관로 목록, 결과/설정 다운로드 preview, 저장 payload preview가 동작하도록 FE-only mock 흐름을 연결했다. + - `map/page.tsx`에는 popup과 동기화되는 선택 지역 강조, 예측강우 sample grid overlay, 설정 편집 모드, 지도 하수관로 클릭 기반 staged 추가 흐름을 연결했다. + - 실제 API 호출은 아직 붙이지 않았고, BE 신규 API 계약과 mock 상태를 기준으로만 동작한다. +- 2026-05-19 + - 결과 조회/설정 현황 mock 데이터를 확대해 페이지네이션이 실제로 보이도록 조정했다. + - 결과 조회와 설정 현황 toolbar 버튼을 우측 정렬로 정리하고, 본문 행 배경과 헤더 컬러를 관로분석 계열 톤으로 맞췄다. + - 지도 상세정보 카드는 프로토타입 표 구조, 위험/정상 배지, 유출유량 초과 강조, compact spacing을 기준으로 재정리했다. + - 지도 상세정보 카드는 선택 중점관리지역의 오른쪽에 붙어 움직이는 프로토타입 기준으로 다시 조정했다. + - 지도 강조와 상세정보 카드는 계속 실험이 반복된 항목이라, 최종 운영 규칙은 BE 연동 및 수동 브라우저 검증 단계에서 한 번 더 확정한다. + +## 3. 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 근거 | +| --- | --- | --- | --- | +| 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 완료 | 지도 이동, 강조 overlay, 상태 연결, 상세정보 카드 프로토타입 | +| 우측 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 완료 | `통수위험` 아이콘 진입점 추가 | +| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 완료 | 신규 popup 정의 | +| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 완료 | popup content 연결 | +| 신규 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/*` | 완료 | 결과/설정 UI, mock 데이터, 타입 분리 | +| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/WaterRiskPopupContent.tsx` | 완료 | window popup 본문 연결 | +| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 초기 구현은 `apps/sn` 내부 해결 우선 | + +## 4. 화면 흐름 + +- 진입 경로 + - 하수관망도 지도 화면 우측 기능 아이콘 `통수위험` +- 정상 흐름 + - 결과 조회 popup 열기 + - 조회조건 입력 + - 결과 목록 표시 + - 결과 행 선택 후 지도 강조/상세정보 표시 + - `설정`으로 화면 전환 + - 설정 현황 조회 + - 중점관리지역 선택 후 유출관로 staged 편집 + - 저장 payload 생성 +- 오류 흐름 + - 날짜/시간 누락 + - 결과 없음 + - 중복 유출관로 선택 + - 3건 초과 선택 + - 저장 실패 시 staged 목록 유지 + +## 5. API 연동 + +| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | +| --- | --- | --- | --- | --- | --- | +| 결과 조회 | `GET` | `/api/v1/map/wraResults` | 분석일시, 지역, 위험상태 | 요약 + 결과 목록 + 관로 반복형 데이터 | BE 신규 필요 | +| 지도 상세 | `GET` | `/api/v1/map/wraResults/{focusAreaId}/mapView` | 중점관리지역 식별값 | polygon + rain grid + 상세 popup 데이터 | BE 신규 필요 | +| 설정 현황 조회 | `GET` | `/api/v1/map/wraOutletPipeSettings` | 지역 조건 | 설정 목록 | BE 신규 필요 | +| 설정 저장 | `POST` | `/api/v1/map/wraOutletPipeSettings/save` | 중점관리지역 + 유출관로 1~3건 + 순번 | 저장 결과 + 최신 상태 | BE 신규 필요 | +| 결과 다운로드 | `POST` | `/api/v1/map/wraResults/download` | 현재 조회조건 | 파일 또는 파일 토큰 | BE 신규 필요 | +| 설정 다운로드 | `POST` | `/api/v1/map/wraOutletPipeSettings/download` | 현재 조회조건 | 파일 또는 파일 토큰 | BE 신규 필요 | + +- FE 메모 + - 1차 구현은 실제 네트워크 대신 payload preview 또는 mock service 기준으로 상태를 먼저 맞춘다. + - 동적 관로 그룹은 `pipeSummaries[]` 반복 렌더링 기준으로 구현한다. + +## 6. 영향 범위 + +- `apps/sn` 영향 + - 지도 메인 화면 + - 우측 분석 기능 메뉴 + - popup registry와 popup content + - 지도 선택/강조/overlay 상태 + - 지도 상세정보 카드와 표형 mock UI +- `business_components` 영향 + - 현재 계획상 없음 + - overlay 또는 popup wrapper 요구가 기존 패턴으로 해결되지 않을 때만 추가 검토 +- 백엔드 영향 + - 결과 조회/상세/설정/저장/다운로드 API + - 권한과 지역 조건 cascade + +## 7. 구현 범위 분리 + +### 7.1 FE 단독 구현 범위 + +- popup 레이아웃 +- 조회/설정 폼 +- mock 결과/설정 데이터 +- 동적 관로 그룹 컬럼 +- 위험/정상 스타일 변경 +- 예측강우 sample overlay +- staged 유출관로 목록 +- 저장/다운로드 payload preview + +### 7.2 BE 필요 범위 + +- 실제 분석 결과 수치 +- geometry와 grid 데이터 +- 저장/다운로드 +- 권한 검증 +- 중복/유효성 서버 검증 + +## 8. 검증 방법 + +- 브라우저 기준 + - `통수위험` 아이콘 클릭 + - 결과 popup 열림 + - 날짜/시간 필수값 검증 + - 위험상태 필터 동작 + - 1건/2건/3건 동적 관로 그룹 표시 + - 페이지네이션 노출과 행 수 변화 + - 결과 행 선택 후 지도 강조 + - 설정 화면 전환 + - 유출관로 staged 목록 추가/삭제/순번 변경 + - 저장 payload preview 확인 +- 정적 확인 + - FE-only 프로토타입 코드는 완료됐고, 실제 브라우저 검증/BE 연동은 후속으로 진행한다. + +## 9. 현재 결정 상태 + +- popup 구조는 결과 조회와 설정 현황을 같은 popup 내부 전환 구조로 유지한다. +- 격자 overlay는 FE 1차 범위에 sample overlay로 포함했다. +- 다운로드는 실제 파일 처리 없이 preview까지를 FE 프로토타입 범위로 본다. +- staged 설정 편집 화면은 popup 내부 + 지도 연동 구조를 유지한다. + +## 10. 롤백 방법 + +- `통수위험` 아이콘 진입점 제거 +- waterRisk 전용 popup/hook/overlay 제거 +- 기존 지도 기본 기능만 남기기 + +## 11. 예상 위험 + +- 동적 관로 그룹 헤더/컬럼 렌더링이 테이블 공통 컴포넌트와 맞물리며 복잡해질 수 있다. +- 지도 강조와 예측강우 격자 overlay가 동시에 올라가면 렌더 성능 이슈가 생길 수 있다. +- 설정 편집 모드에서 기존 피처 클릭 상세와 충돌 가능성이 있다. +- 상세정보 카드의 지도 기준 고정 규칙은 사용자 피드백에 따라 다시 조정될 수 있다. + +## 12. 완료 기준 + +- `통수위험` 진입과 popup UI가 동작한다. +- 결과/설정 화면 전환이 가능하다. +- 동적 관로 그룹 렌더링이 1~3건 모두 자연스럽다. +- 지도 강조/상세정보/mock 격자 표시가 가능하다. +- staged 설정 목록과 저장 payload preview가 준비된다. +- FE-only 프로토타입 기준 구현이 완료되고, BE 연동 및 실제 통합 검증 후속 범위가 문서로 정리된다. +- 지도 상세정보 카드 위치 규칙은 현재 프로토타입 기준 동작으로 마감하고, 운영 기준 확정은 후속 검증으로 넘긴다. diff --git a/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md new file mode 100644 index 000000000..574acdb4d --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md @@ -0,0 +1,117 @@ +# SFR-011-009 침수발생이력 작업카드 + +## 1. 작업 개요 + +- 작업 ID: `SFR-011-009-FE` +- 작업명: 침수발생이력 프론트엔드 구현 +- 상태: 검증 +- 작성일: 2026-06-15 +- 담당 역할: FE +- 관련 역할: DE, QA +- 담당 범위: `apps/sn` 지도 화면에서 레이어 트리 `침수흔적도` 목록 popup, 목록 조회, 지도 preview, 지도 클릭 플로팅 상세 팝업, mock 침수심 overlay 구현 +- 근거 문서 + - `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/0.침수발생이력_요구사항명세서_v2.0.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/5.침수발생이력_화면시나리오_V2.0.md.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 목록화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 지도상세정보화면설계서.png` + - `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력_화면.html` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: `map/page.tsx`, `windowPopup/**`는 다른 지도형 기능과 최근 수정 이력이 있어 충돌 주의가 필요하다. + +## 2. 작업 목적 + +- 레이어 트리 `침수흔적도` 목록 진입을 placeholder가 아닌 실제 popup UI로 연결한다. +- popup 조회 결과를 메인 지도와 연동해 사용자가 선택한 침수발생이력을 즉시 확인할 수 있게 한다. +- 설계 이미지의 `침수흔적도 on/off + 목록 popup + 지도 강조 + 지도 위 상세 팝업` 흐름을 현재 `sn` 지도형 구조 안에서 자연스럽게 재현한다. + +## 3. 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 비고 | +| --- | --- | --- | --- | +| 메인 지도 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 진행 | popup 선택 결과 수신, 지도 overlay, 우측 상세패널 연결 | +| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 진행 | tree 목록 진입 popup 키/크기 정의 | +| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 진행 | placeholder에서 실제 popup content로 교체 | +| 분석도구 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 진행 | 분석도구에서 `침수발생이력` 제거 | +| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx` | 진행 | popup 본문 연결 | +| 신규 popup 페이지 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx` | 진행 | 조회조건/목록/다운로드 | +| 신규 상세패널 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx` | 진행 | 기본정보/발생일시/원인·피해 탭 | +| 신규 타입/샘플데이터 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` | 진행 | mock 데이터, postMessage 타입, legend 정의 | + +## 4. 구현 범위 + +### 4.1 이번 라운드 포함 + +- 레이어 트리 `침수흔적도` 목록 popup 진입 +- 레이어 트리 `침수흔적도` on/off +- 시도/시군구/침수발생년도 선택/침수재해명 조회조건 +- mock 목록 조회/페이징 +- 목록 다운로드 +- 목록 행 클릭 preview + 더블클릭 후 메인 지도 이동 +- 메인 지도 mock polygon/침수심 overlay +- 지도 클릭 플로팅 상세 팝업 +- `강우 분석정보` 묶음 헤더 유지 + 상세 필드 가로 스크롤 그리드 + +### 4.2 이번 라운드 제외 + +- 실제 백엔드 API 연동 +- 실제 레이어 트리 토글 연계 +- 지도 click 상세조회 +- 실제 기준데이터 기반 GIS layer 렌더링 + +## 5. 화면 흐름 + +1. 사용자가 레이어 트리에서 `침수흔적도`를 체크한다. +2. 사용자가 `침수흔적도` 목록 버튼을 클릭한다. +3. popup이 열린다. +4. 사용자가 조회조건을 입력하고 `조회`를 클릭한다. +5. 목록이 popup 그리드에 표시된다. +6. 사용자가 행을 클릭하면 메인 지도에 테스트 레이어가 먼저 표시된다. +7. 사용자가 행을 더블클릭하면 popup이 닫히고 메인 지도는 해당 침수 이력 영역으로 이동한다. +8. `침수흔적도` 체크 상태에 따라 영역/침수심 표현이 함께 보인다. +9. 메인 지도 위 큰 침수흔적 영역을 클릭하면 작은 상세 팝업이 열린다. + +## 6. 구현 메모 + +- popup은 기존 `windowPopup` 체계를 그대로 사용하되, 분석도구가 아니라 레이어 트리 `침수흔적도` 목록 버튼으로 연다. +- 목록 선택 결과는 `window.opener.postMessage`로 메인 지도에 전달한다. +- popup의 `침수발생년도`는 같은 필터 줄 안의 선택 컴포넌트로 처리한다. +- 메인 지도는 `VectorLayer` 두 개를 사용해 + - 침수발생이력상세 영역 + - 침수심 표현 영역 + 을 임시 렌더링한다. +- 두 overlay는 `침수흔적도` 체크 상태를 따르도록 맞춘다. +- 상세정보는 우측 패널이 아니라 지도 위 플로팅 팝업으로 노출하고, 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 기준으로 보여준다. + +## 7. 검증 방법 + +- 레이어 트리 `침수흔적도` 목록 popup이 열린다. +- `침수흔적도` 체크 on/off에 따라 영역/침수심 overlay가 함께 바뀐다. +- 시도 없이 시군구를 선택하면 조회가 차단된다. +- 침수발생년도 선택이 동작한다. +- 조건 변경 시 목록 필터가 바뀐다. +- 행 클릭 시 지도 preview가 표시된다. +- 지도 위 큰 polygon 클릭 시 작은 상세 팝업이 열린다. +- 상세 팝업 탭 전환이 동작한다. + +## 8. 현재 메모 + +- 문서 우선 원칙에 맞춰 이번 라운드는 설계서/작업카드/검증계획을 먼저 정리한 뒤 구현을 이어간다. +- 현재 코드에는 일부 draft 구현이 이미 들어가 있으므로, 이후 단계에서 이 작업카드 기준으로 정합성을 다시 맞춘다. + +## 9. 롤백 방법 + +- 레이어 트리 목록 진입 연결 해제 +- `floodHistory` 신규 컴포넌트 삭제 +- `map/page.tsx`의 floodHistory overlay/상세패널 분기 제거 + +## 10. 남은 리스크 + +- 실제 API 필드명이 mock 데이터와 다를 수 있다. +- 실제 지도 레이어 기준데이터가 준비되면 현재 mock overlay 방식과 상태 관리가 달라질 수 있다. +- popup 상세정보를 우측 패널로 둘지 지도 내부 floating popup으로 둘지는 실제 사용성 피드백 후 재조정 가능성이 있다. diff --git a/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md b/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md new file mode 100644 index 000000000..af3d4b3e6 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md @@ -0,0 +1,175 @@ +# SFR-012-002 하수관망도 업로드 작업카드 + +## 1. 작업 개요 + +- 작업 ID: SFR-012-002-FE +- 작업명: 하수관망도 업로드 프론트엔드 구현 계획 +- 상태: 완료 +- 작성일: 2026-05-15 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `GIS 관리` popup UI, 시설구분 선택, 파일 찾기, mock 업로드/다운로드 흐름, 업로드 payload 준비 상태까지 +- 운영 제약: `GIS 관리`는 하수관로만 업로드 대상으로 사용하고, 다른 GIS 테이블은 FE 시설구분 목록에서 제외 +- 구현 제외: 실제 서버 업로드/다운로드 연결, 백엔드 저장 로직, DB 반영 +- 근거 문서 + - `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_요구사항명세서_v1_1.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_시나리오_v1.0.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_화면_v1_1.html` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로두_화면설계서_v1.0.png` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계 + - `SFR-012-003`에서 `GIS 편집`은 지도 로컬 모드로 구현됐다. + - 이번 `SFR-012-002`는 같은 우측 기능 버튼 그룹을 사용하지만 popup 업무 흐름으로 분리해야 한다. + +## 1-2. 최근 설계 갱신 + +- 2026-05-15 + - `SFR-012-003`의 문서 구조를 기준으로 `SFR-012-002` canonical 문서 세트를 신규 작성한다. + - 이번 기능의 FE 핵심은 `map/page.tsx` 직접 수정 확대보다 `gisManagementPopup` placeholder를 실제 popup content로 교체하는 데 있다. + - 승인 전에는 코드 수정 없이 설계, 작업카드, 구현기록, 검증 문서 초안까지만 진행한다. +- 2026-05-15 구현 반영 + - `gisManagementPopup` placeholder를 실제 업로드 관리 popup content로 교체했다. + - 시설구분 선택, 파일 찾기, mock 업로드 이력 추가, 다운로드 요청 미리보기, 업로드/다운로드 payload 미리보기까지 FE-only 범위를 반영했다. + - 실제 multipart 업로드와 다운로드 바이너리 처리, 서버 이력 재조회는 후속 BE 연동 단계로 남긴다. +- 2026-05-18 구현 마감 정리 + - popup 구현 구조를 `windowPopup` wrapper + 실제 popup page 컴포넌트 분리 방식으로 재구성했다. + - `GIS 관리` popup 디자인을 분석도구 계열의 타이포와 색 축에 맞춰 재정렬했다. + - 업로드 현황은 10건 단위 페이징을 붙이고, 팝업을 닫으면 분석도구 메뉴의 active 상태도 같이 해제되도록 보완했다. + - `찾기`, `업로드` 버튼은 상황판/검색영역 `조회` 버튼 계열 색으로 정리했고, `다운로드` 버튼은 운영정보 `파일 다운로드`와 같은 `green` 계열로 정리했다. + - 업로드 row는 좌측 입력 그룹과 우측 버튼 그룹을 분리해 버튼 위치와 간격을 조정했다. + +## 2. 작업 목적 + +- 기존 지도 화면의 `GIS 관리` 기능을 실제 업로드 관리 popup으로 구체화한다. +- 사용자가 하수관로 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다. +- 실제 백엔드 연결 전에도 UI, 상태, mock 데이터, 업로드 요청 payload 구조를 검토할 수 있도록 준비한다. + +## 3. 예상 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 근거 | +| --- | --- | --- | --- | +| popup registry | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 예정 | `gisManagementPopup` placeholder를 실제 content로 교체 | +| popup definition | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 가능성 있음 | popup 크기 조정 필요 시 | +| 신규 popup content | `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx` | 예정 | 업로드 관리 popup 본문 | +| 신규 보조 컴포넌트 | `FE_APP_ROOT/src/features/windowPopup/ui/gisManagement/*` | 가능성 있음 | 폼/그리드/상태영역 분리 시 | +| 지도 메인 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 가능성 낮음 | popup open 이후 동기화가 필요할 때만 최소 수정 | +| 우측 기능 버튼 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 기본 제외 | 버튼 key와 open 흐름이 이미 존재 | + +## 3-1. 이번 라운드 실제 수정 파일 + +- `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx` +- `FE_APP_ROOT/src/features/windowPopup/registry.tsx` +- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` +- `FE_APP_ROOT/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + +## 4. 화면 흐름 + +- 진입 경로 + - 하수관망도 지도 화면 + - 우측 기능 버튼 `GIS 관리` +- 사용자 조작 + - `GIS 관리` 클릭 + - 시설구분 선택 + - `찾기` 클릭 후 파일 선택 + - `업로드` 클릭 + - 업로드 현황 행 선택 + - `다운로드` 클릭 +- 정상 흐름 + - popup 열림 + - 시설구분 선택 전 `찾기`/`업로드` 안내 + - 파일 선택 후 파일명 표시 + - 업로드 후 현황 목록 갱신 + - 다운로드 대상 선택 후 다운로드 요청 가능 상태 +- 오류 흐름 + - 시설구분 미선택 + - 파일 미선택 + - 미지원 확장자 + - 업로드 실패 + - 다운로드 대상 미선택 + +## 5. FE 단독 구현 범위 + +- popup UI 렌더링 +- 시설구분 select 상태 +- 브라우저 파일 선택 상태 +- mock 업로드 이력 목록 +- 업로드 시 mock 행 추가 +- 다운로드 대상 선택 상태 +- 상태 메시지 및 토스트 +- 업로드 payload 직렬화 준비 + +## 6. 백엔드 필요 범위 + +- 업로드 가능 시설구분/허용 확장자/최대 파일 크기 메타 API +- 업로드 현황 조회 API +- GIS 파일 업로드 API +- GIS 파일 다운로드 API +- 원본 분리 저장 및 최신 참조 갱신 + +## 7. API 연동 초안 + +| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | +| --- | --- | --- | --- | --- | --- | +| 시설구분 메타 조회 | `GET` | `/api/v1/map/gisUploadFacilityTypes` | `screenId` | 시설구분, 허용 확장자, 업로드 가능 여부 | BE 신규 필요 | +| 업로드 현황 조회 | `GET` | `/api/v1/map/gisUploadHistories` | `screenId`, `facilityTypeCode` | 업로드 이력 목록 | BE 신규 필요 | +| GIS 업로드 | `POST` | `/api/v1/map/gisUploads` | `multipart/form-data` | 업로드 결과, 참조 갱신 정보 | BE 신규 필요 | +| GIS 다운로드 | `GET` | `/api/v1/map/gisUploads/{uploadId}/download` | `uploadId` | 파일 다운로드 | BE 신규 필요 | + +## 8. 영향 범위 + +- `apps/sn` 화면 영향 + - 지도 화면 우측 기능 버튼의 `GIS 관리` popup 실제 동작 + - popup 내부 폼/그리드/상태 메시지 +- 지도 기능 영향 + - 지도 이동, 확대/축소, 레이어 트리 기존 동작은 유지돼야 한다. +- 공통 비즈니스 컴포넌트 영향 + - 현재 계획상 없음 +- 타 앱 영향 가능성 + - 없음 + +## 9. 검증 방법 + +- 브라우저 + - `GIS 관리` popup 열림 + - 시설구분 미선택 시 차단 + - 파일 선택 후 파일명 노출 + - mock 업로드 후 목록 갱신 + - 다운로드 대상 선택 상태 + - popup 종료 시 분석도구 메뉴 active 해제 + - 10건 단위 페이징과 첫 화면 5행 수준 밀도 확인 + - `찾기`, `업로드`, `다운로드` 버튼 색/간격 확인 +- 정적 확인 + - payload preview 또는 직렬화 결과 점검 +- 이번 라운드 수행 결과 + - `http://127.0.0.1:3002/popup/gisManagementPopup` 응답 `200` + - popup 응답 HTML에서 `GIS 업로드`, `GIS 업로드 현황`, `시설구분`, `다운로드` 문자열 확인 + - `pnpm --filter sn exec tsc --noEmit --pretty false --incremental false` 실행 시 기존 저장소 타입 오류로 실패했으며, 이번 popup 파일 관련 신규 오류는 출력되지 않았다. +- 보류 항목 + - 실제 multipart 업로드 응답 + - 실제 다운로드 파일 수신 + +## 10. 롤백 방법 + +- `gisManagementPopup`을 placeholder content로 되돌린다. +- 신규 popup content와 관련 mock 상태를 제거한다. + +## 11. 예상 위험 + +- popup 구현 위치가 `src/features/windowPopup/**`라 지도 화면 코드와 책임 경계를 명확히 잡지 않으면 유지보수가 어려워질 수 있다. +- 파일 업로드는 브라우저 객체와 서버 multipart 계약이 어긋나기 쉬워 FE-only 구현이 오래 임시 상태로 남을 수 있다. +- 업로드 후 실제 지도 레이어 refresh 시점은 서버 계약 없이는 임의로 확정하기 어렵다. +- 시설구분 목록은 아직 실제 `시설물 레이어`나 업로드 메타 API를 조회하지 않고 mock 값으로 유지된다. + +## 12. 완료 기준 + +- FE 범위의 `GIS 관리` popup UI, mock 업로드 흐름, 페이지네이션, active 상태 해제가 반영됐을 것 +- 예상 수정 파일, 화면 흐름, BE 필요 범위가 문서로 정리됐을 것 +- 실제 업로드/다운로드 API 연동 전까지 FE와 BE 경계가 분리돼 있을 것 diff --git a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md new file mode 100644 index 000000000..9d73683f5 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md @@ -0,0 +1,292 @@ +# SFR-012-003 하수관로편집 작업카드 + +## 1. 작업 개요 +- 작업 ID: SFR-012-003-FE +- 작업명: 하수관망도 GIS편집 프론트엔드 구현 계획 +- 상태: 진행 +- 작성일: 2026-05-14 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 관로 기본정보 세션 편집, 저장 payload 준비까지 구현 +- 근거 문서: + - `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_요구사항명세서_v1_1.docx` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면_v1_1.html` + - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png` + +## 1-2. 최근 진행 갱신 +- 2026-06-29 + - 원본 하수관로 WFS snap source를 우선 사용할 때 사용자 임시 추가 관로 preview가 스냅 대상에서 빠지는 구조를 확인했다. + - `page.tsx`의 pipe snap source 로더를 보강해 현재 세션의 `ADD`/`MOVE`/`VERTEX_EDIT` preview 관로도 함께 병합하고, 기존 수정 관로의 원본 fid는 숨겨 stale geometry로 스냅되지 않게 했다. + - `GIS 편집 세션` popup 버튼은 `취소/뒤로가기/초기화` 3종으로 재정렬하고, `취소`는 현재 선택 change 제거, `뒤로가기`는 기존 마지막 변경 취소 의미로 분리했다. + - 정적 타입체크 결과 이번 변경 파일 신규 오류는 없었고, 저장소 기존 오류만 남았다. 브라우저 수동 검증은 다음 범위로 남긴다. +- 2026-06-26 + - `MOVE` 도구가 관로 1회 이동 후 자동 해제되는 현상을 요구사항 분석 범위로 확인했다. + - 현재 원인은 `useGisEdit.updateChangeGeometry()` 기본 경로가 geometry 갱신 뒤 `activeTool`과 `pendingInteraction`을 모두 비우는 구조이고, `page.tsx`의 `MOVE translateend`가 이 함수를 옵션 없이 호출하는 데 있다. + - 구현은 `MOVE` 완료 후 `activeTool="MOVE"`를 유지하고 `pendingInteraction`만 비워 다음 관로 클릭을 새 이동 대상으로 받는 방식으로 반영했다. + - 정적 타입체크는 실행했으나 저장소 기존 오류에서 실패했고, 실제 브라우저 연속 이동 검증은 다음 수동 검증 범위로 남긴다. +- 2026-06-08 + - `MOVE`와 `꼭지점편집` 사이에 `스냅핑` on/off 버튼을 추가하고, 편집 세션/상태 스냅샷에도 `ON/OFF`가 보이도록 연결했다. + - 기존 임시 스냅 수집 방식으로는 원본 오수관에 안정적으로 붙지 않는다고 판단해, 1차 재구현 범위를 `ADD`, `VERTEX_EDIT`로 줄였다. + - `ADD`, `VERTEX_EDIT`는 현재 화면 BBOX 기준으로 읽어온 `tb_ms_swage_ppl` 원본 WFS geometry를 전용 snap source로 사용하도록 재구성했다. + - 후속 보정으로 `MOVE`도 같은 원본 하수관로 snap source를 사용하도록 넓혔고, 이동 기준점은 사용자가 처음 집은 관로 꼭지점(grip vertex)으로 다시 고정했다. + - `MOVE` 중 관로 입력정보 조회에서 `500`이 발생하는 케이스를 줄이기 위해 상세 팝업의 path segment를 인코딩하고 `NEW-*` 임시값 조회를 차단했다. + - `VERTEX_EDIT` 중 주황색 실꼭지점을 클릭해 선택한 뒤 키보드 `Delete`로 삭제할 수 있게 했고, 하수관로 최소 2점 유지 규칙을 넣었다. + - 흰색 midpoint는 새 점 삽입 전용으로 유지하고, Delete 삭제 대상에서는 제외했다. + - 수동 브라우저 재검증은 아직 남아 있고, 정적 타입체크는 저장소 기존 오류와 별개로 이번 변경분 신규 오류는 정리했다. +- 2026-05-21 + - 실제 구현 코드를 다시 점검한 결과, 현재 GIS편집은 geometry 세션만 관리하고 관로 기본정보 세션은 없는 상태로 정리했다. + - `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조가 분리돼 있어, 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 기준으로 재설계했다. + - 편집 모드에서 하수관로 change를 선택하면 기본정보만 세션 저장할 수 있는 편집 상세정보창을 추가하고, 최종 저장은 geometry와 기본정보를 함께 조립하는 방향으로 구현 중이다. + - 하수관로 편집 팝업의 입력정보 저장버튼은 제거하고, 입력 변경 시 세션 draft가 바로 반영되도록 수정했다. + - 편집 팝업 입력 시 스크롤 상단 점프와 `Maximum update depth exceeded` 에러 원인을 자동저장 effect 루프로 보고 제거했다. + - `MOVE`, `VERTEX_EDIT` 중에는 하수관로만 선택 가능하게 차단하고, 하수관로 선택 즉시 편집 상세정보창이 열리도록 보강했다. +- 2026-05-14 + - 7개 편집 레이어 확장, 레이어별 허용 도구 규칙, 로컬 세션 undo/reset, `MOVE`/`VERTEX_EDIT`/`DELETE` 임시 변경분 누적까지 반영했다. + - 이번 라운드에서 `MOVE`를 preview feature 기반 실제 drag 흐름으로 올리고, 지도 위 preview 강조와 재선택 UX를 보강했다. + - `VERTEX_EDIT` 구현은 QGIS 기준 사용자 경험과 어긋나는 구간이 확인되어 재설계가 필요하다. + - 재구현 기준은 `ADD=geometry family별 직접 캡처`, `MOVE=현 방식 유지`, `VERTEX_EDIT=양 끝점 포함 vertex marker 조작`으로 다시 고정한다. + - 다음 핵심 잔여 범위는 설계 문서 기준 재정렬 후 `ADD`, `VERTEX_EDIT`, `DELETE`를 순서대로 안정화하는 것이다. + +## 1-3. 브라우저 검증 선행조건 +- 접속 URL: `sn` 지도 메인 화면 진입이 가능해야 한다. +- 로그인/권한: `/map` 진입과 지도 API 호출이 가능한 계정 또는 로컬 임시 로그인 경로가 준비되어야 한다. +- 서버 조건: FE dev 서버, BE API, 프록시 또는 gateway 경로가 응답해야 한다. +- 데이터 조건: 편집 대상 레이어, 샘플 객체, 테스트 BBOX를 확보해야 한다. +- 인증 차단 시 대체 경로: `COMMON_DOCS_ROOT/작업내용/공통-로그인/001-de-공통-로그인-로컬임시로그인-검토계획.md` 기준으로 로컬 로그인 가능 여부를 먼저 확인한다. +- 자동화 한계 예상 항목: canvas drag, vertex 직접 조작은 수동 검증 전환 가능 항목으로 본다. + +## 1-1. 공통 동기화 확인 +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계: 지도 메인 화면 `map/page.tsx`는 최근 유하방향표출 작업 이력이 있어 동일 파일 충돌 주의가 필요하다. + +## 2. 작업 목적 +- 기존 하수관망도 지도 화면에 `GIS편집` 기능을 추가해 사용자가 편집 대상 레이어를 고른 뒤 객체 추가, 이동, 꼭지점편집, 삭제, 저장까지 한 흐름에서 수행할 수 있게 한다. +- 프론트는 저장 전까지 편집 변경분을 로컬 세션으로 관리하고, 저장 시 BE가 제공할 편집 저장 API에 맞춰 payload를 전달한다. +- 백엔드 개발자가 바로 이어받을 수 있도록 필요한 API, 화면 상태, 예외 흐름을 FE 기준으로 구체화한다. +- 실제 API 구현은 범위 밖이며, 백엔드 공유용 요청 사항은 `작업내용` 문서로 분리해 관리한다. +- 추가 운영 제약: + - `GIS편집`, `GIS관리`는 하수관로만 대상으로 제한한다. + - 다른 GIS 테이블은 타회사 소유 범위로 보고 FE에서 선택/업로드 대상으로 노출하지 않는다. + +## 3. 수정 대상 파일 +| 구분 | 파일 경로 | 수정 여부 | 근거 | +| --- | --- | --- | --- | +| 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 예정 | 지도 메인 화면에서 우측 기능 버튼, 편집 모드 상태, OpenLayers interaction 연결 필요 | +| 컴포넌트 | `FE_APP_ROOT/app/(map-layout)/map/components/gisEdit/*` | 예정 | 툴바, 세션 패널, 편집 훅 분리 후보 | +| 팝업/선택 | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | 가능성 있음 | 편집 모드 중 기존 상세 팝업 충돌 차단 필요 | +| API 호출 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 또는 `components/gisEdit/*` | 부분 완료 | 현재는 저장 payload 미리보기까지 구현, 실제 API 호출은 후속 | +| 공통 비즈니스 컴포넌트 | `FE_BC_ROOT/**` | 기본 제외 | 초기 구현은 `apps/sn` 내부 해결 우선 | + +## 3-1. 쓰기 범위와 충돌 주의 +| 구분 | 경로 | 담당 역할 | 처리 기준 | +| --- | --- | --- | --- | +| 쓰기 허용 | `apps/sn/**` | FE | 지도 화면, 편집 상태, API 호출 작업 | +| 쓰기 허용 | `packages/common/business_components/**` | FE | 꼭 필요한 경우에만 최소 범위 수정 | +| 쓰기 금지 | `packages/common/business_components` 외 공통 패키지 | FE | 문제 원인과 영향 범위만 기록 | +| 쓰기 금지 | 타 앱 `apps/*` | FE | 문제 원인과 영향 범위만 기록 | + +- 충돌 주의 파일: + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` +- 인수인계 메모: + - `map/page.tsx`는 이미 레이어 트리, 검색, 유하방향표출까지 담당 중이라 편집 기능을 그대로 누적하면 과도하게 비대해질 수 있다. + - 가능하면 `components/gisEdit` 하위로 상태/뷰를 나눠야 한다. +- 파일점유 등록 여부: 공통 파일점유현황에 계획 작업 반영 + +## 4. 화면 흐름 +- 진입 경로: `관망도편집 > 하수관망도 편집`의 지도형 메인 화면 +- 사용자 조작: + - 우측 `GIS편집` 클릭 + - 편집 대상 레이어 선택 + - `추가`, `이동`, `꼭지점편집`, `삭제` 중 선택 + - 저장 클릭 +- 정상 흐름: + - 편집 모드 진입 + - 레이어 선택 전 도구 비활성 + - 레이어 선택 후 도구 활성 + - `추가`는 geometry family별로 사용자가 지도에서 직접 형상을 캡처 + - `이동`은 preview drag로 위치 조정 + - `꼭지점편집`은 선택 객체의 vertex marker를 노출하고 양 끝점을 포함해 이동 + - 변경분이 로컬 편집 세션에 누적 + - 하수관로 편집은 기존 상세정보창 구조를 재사용하고, `입력정보` 저장은 세션 저장으로 처리 + - 저장 시 원본과 분리된 편집본 저장 요청 +- 빈 결과 흐름: + - 변경분이 없으면 저장 비활성 또는 안내 메시지 표시 +- 오류 흐름: + - 편집 대상 미선택 + - 미지원 레이어 선택 + - 객체 미선택 + - geometry 오류 + - 저장 실패 시 임시 변경분 유지 + +## 5. API 연동 +| 기능 | Method | URL | 요청 | 응답 | 확인 상태 | +| --- | --- | --- | --- | --- | --- | +| 기존 레이어 트리 조회 | `GET` | `/api/v1/map/lyrStngs/SN_NETWORK` | 기존 지도 레이어 그룹 | 레이어 트리 데이터 | 기존 사용 중 | +| 편집 가능 레이어 메타 조회 | `GET` | `/api/v1/map/gisEditLayers` | `lyrGrp`, `screenId` | 편집 가능 레이어, geometry 타입, 허용 도구 | BE 신규 필요 | +| 관로 기본정보 조회 | `GET` | `/api/v1/fcltppl/{fcltMngNo}/{mngInstCd}` | 관로 식별자 | `PplDto` 기본정보 7개 필드 | 기존 사용 중 | +| 편집 저장 | `POST` | `/api/v1/map/gisEditSessions/save` | 레이어 정보, view, `changes[]` | `sessionId`, `editFileId`, `versionNo`, `savedCount` | BE 신규 필요 | + +- FE 메모: + - 저장 전까지 변경분은 FE 로컬 상태로 보관한다. + - 관로 상세정보창의 저장은 즉시 API 호출이 아니라 change별 세션 저장으로 해석한다. + - 현재 구현은 실제 최종 저장 호출 대신 payload 미리보기만 생성한다. + - BE 요청 사항은 `FE_APP_DOCS_ROOT/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`에 별도 정리한다. + +## 6. 영향 범위 +- `apps/sn` 화면 영향: + - 지도 메인 화면 상단 툴바 + - 우측 기능 버튼 + - 편집 세션 패널 + - 기존 피처 클릭 상세와의 충돌 제어 +- `business_components` 영향: + - 초기 계획상 없음 + - `Gis` 공통 확장이 꼭 필요해지면 별도 승인/영향분석 후 검토 +- 백엔드 API 영향: + - 편집 가능 레이어 메타 API + - 저장 API + - 원본 분리 저장 및 참조 갱신 로직 +- 다른 앱 영향 가능성: + - 현재 계획상 없음 + - `apps/sn` 내부 구현 우선 + +## 6-1. 멀티에이전트 작업 분리 +- FE 담당 산출물: + - 편집 모드 토글 + - 툴바 및 세션 패널 + - OpenLayers 편집 interaction + - 저장 payload 조립 +- BE 담당 산출물: + - 편집 가능 레이어 메타 조회 + - 저장 API + - 원본 분리 저장, 편집본 참조 갱신 +- QA 담당 산출물: + - 편집 대상 미선택/객체 미선택/geometry 오류/저장 실패 검증 + - 저장 후 원본 보호 검증 +- 병렬 진행 가능 항목: + - FE 로컬 상태/툴바 UI 설계 + - BE API 명세 확정 +- 순차 진행 필요 항목: + - 저장 payload 최종 필드 확정 후 FE-BE 연동 검증 + +## 6-2. 채팅방 간 충돌 방지 +- 같은 기능을 다른 채팅방에서 진행 중인지: 현재 공통 작업현황판에는 신규 기능 구현 행이 아직 없다. 이번 문서 작성으로 최초 등록한다. +- 겹치는 파일 또는 폴더: + - `map/page.tsx` + - `featureClickEventPopup.tsx` + - `FE_APP_DOCS_ROOT/작업계획/**`는 문서 재정리 흔적이 있어 경로 유지 주의 +- 점유 충돌 시 처리 방식: + - 문서와 실제 코드 작업을 분리하고, 코드 착수 전에 파일점유현황을 다시 갱신한다. + +## 7. 검증 방법 +- 빌드 명령: + - 개발 서버 미실행 확인 후 `pnpm --filter sn run build` + - 필요 시 `pnpm turbo run build --filter=sn` +- 브라우저 URL: + - 기본 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 진입 화면 +- 사용자 조작: + - `GIS편집` 클릭 + - 레이어 선택 전/후 도구 활성 상태 확인 + - `추가`, `이동`, `꼭지점편집`, `삭제` 각각 1회 수행 + - 저장 클릭 +- 기대 결과: + - 레이어 선택 전 편집 도구 비활성 + - 변경분 누적 시 저장 활성 + - 저장 실패 시 임시 변경분 유지 + - 저장 성공 시 원본 보존 메시지 표시 +- 네트워크 응답 확인 기준: + - `gisEditLayers` 응답에 editable 여부와 도구 목록 포함 + - `gisEditSessions/save` 요청에 `changes[]`가 변경 유형별로 정확히 담김 + - 저장 성공 응답에 편집본 식별자와 버전 정보 포함 + +## 7-1. 하네스 관측 포인트 +- 화면 관측 + - `GIS편집` 버튼 강조 여부 + - 툴바 노출 여부 + - 선택 레이어명 + - 상태 메시지 + - 변경 목록 행 수 +- 지도 상호작용 관측 + - `ADD` 완료 후 신규 항목 생성 + - `MOVE` drag 종료 후 preview label과 목록 요약 변화 + - `VERTEX_EDIT` 시 vertex marker 또는 modify handle 노출 + - `DELETE` 시 삭제 예정 스타일 유지 +- 세션 관측 + - `undo`, `reset`, `canSave` + - 저장 실패 시 임시 변경분 유지 +- 차단 사유 분류 + - 기능 결함 + - 인증 차단 + - 환경 차단 + - 데이터 차단 + - 자동화 한계 + +## 8. 롤백 방법 +- `GIS편집` 토글과 편집 툴바 UI 제거 +- `components/gisEdit/*` 신규 파일 제거 +- 편집 저장 API 호출 분기 제거 +- 기존 지도 조회/레이어 트리/피처 상세 흐름만 남기기 + +## 9. 예상 위험 +- `map/page.tsx`가 이미 크기와 책임이 커서 기능 누적 시 회귀 위험이 높다. +- 점형/선형 객체의 편집 interaction이 달라 공통 훅 설계가 어색해질 수 있다. +- 편집 가능 레이어/geometry 타입/저장 대상이 확정되지 않으면 FE 구현이 임의 가정을 하게 될 수 있다. +- 편집 모드 중 기존 피처 클릭 상세 팝업이 뜨면 객체 선택/이동과 충돌할 수 있다. + +## 10. 완료 기준 +- `GIS편집` 버튼으로 편집 모드 on/off 가능 +- 레이어 선택 전 편집 도구 비활성 +- 선택 레이어 기준으로 `추가`, `이동`, `꼭지점편집`, `삭제` 최소 동작 가능 +- 변경분이 로컬 편집 세션에 누적되고 목록으로 확인 가능 +- 저장 payload가 설계 문서 계약과 맞게 구성됨 +- BE 공유용 API 요청 문서가 별도 정리됨 +- 브라우저에서 정상/예외 시나리오를 확인할 수 있음 +- 결과 보고 시 작업 목적, 수정 대상 파일, 영향 범위, 검증 방법, 롤백, 리스크를 정리할 수 있음 +- 2026-06-26 관로 입력정보 조회 오류 방어 + - MOVE on/off 기능 테스트 중 기존 관로 상세 입력정보 조회 실패가 `Error: ERROR` 콘솔 오류로 노출되는 문제를 확인했다. + - `GisEditPipeSessionPopup`은 선택적 입력정보 조회 실패를 편집 세션 중단/콘솔 오류로 처리하지 않고, 기존 WFS/세션 draft 값을 유지하도록 보강한다. + - 백엔드 `PplService`는 MS 보조 상세속성/관리기관명 조회 실패를 전체 API 500으로 전파하지 않고 fallback하도록 보강한다. + +## 11. 2026-07-15 편집 세션 배치 유지보수 + +- 작업 모드: `maintenance` +- 범위: 프론트엔드 `sn` 지도 화면 +- 구현 범위 + - GIS 편집 진입 시 편집 세션을 시설물 관리 옆 인라인 패널로 표시 + - 초기 패널은 내용 높이로 표시하고 변경분 증가에 따라 확장 + - 시설물 접기 토글 전 최대 높이 제한 및 변경 목록 세로 스크롤 + - 하수관로 편집 도구바를 검색/세션 영역 아래로 이동 + - 인라인 패널의 명시적 `팝업` 버튼으로 기존 윈도우 팝업 재사용 +- 제외 범위 + - 편집 저장 API, 편집 도구 로직, 백엔드 및 DB 계약 변경 없음 +- 완료 기준 + - 편집 모드 진입만으로 새 창이 자동 생성되지 않는다. + - 인라인 세션과 기존 팝업의 취소/되돌리기/초기화/포커스 동작이 같은 상태를 사용한다. + - 변경 목록이 최대 높이를 넘으면 패널 전체가 아니라 목록 영역에 세로 스크롤이 생성된다. + +## 12. 2026-07-15 사용자 화면 피드백 2차 유지보수 + +- 11절의 `top: 84px` 중앙 도구바 배치는 사용자 확인 결과를 반영해 폐기한다. +- 수정 완료 기준 + - 인라인 세션 표시 중 검색바의 모든 검색 조건이 가려지지 않는다. + - GIS 편집 도구바는 인라인 세션 바로 아래에 표시되고 세션 높이 증가를 따라 내려간다. + - 팝업을 열면 인라인 세션은 숨는다. + - 팝업의 `화면에서 보기` 또는 팝업 닫기로 인라인 세션이 복원된다. + - 팝업과 인라인은 동시에 보이지 않으며 편집 변경 상태는 유지된다. +- 공통 영향 + - `FE_BC_ROOT/ui/map/Gis.js`에 선택적 검색바 래퍼 클래스 전달 기능을 추가하되 기본 화면 동작은 유지한다. + +## 13. 2026-07-15 표시 영역 기준 최종 배치 + +- 12절의 검색바 이동과 세션 종속 도구바 배치는 사용자 표시 영역 기준으로 대체한다. +- 완료 기준 + - 검색바는 편집 모드 전후 같은 중앙 위치를 유지한다. + - 변경분 0건의 편집 세션은 검색바와 유사한 축약 높이로 상단 표시 영역에 배치된다. + - 변경분이 추가되면 편집 세션만 아래로 확장되고 최대 높이 이후 목록 스크롤이 생긴다. + - GIS 편집 도구바는 하단 표시 영역 중앙에 고정된다. + - 팝업 전환 시 인라인 숨김과 `화면에서 보기` 복귀는 유지된다. +- 공통 `Gis.js`는 수정하지 않는다. diff --git a/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md b/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md new file mode 100644 index 000000000..d0cd924e7 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md @@ -0,0 +1,204 @@ +# SN 침입수분석결과 지도연계 유지보수 작업카드 + +## 1. 작업 개요 + +- 작업 ID: `SN-INFTWT-RESULT-FE` +- 작업명: 침입수분석결과 지도형 popup 및 하이라이트 연계 +- 상태: 검증 +- 작성일: 2026-06-30 +- 담당 역할: FE +- 관련 역할: DE, BE, QA +- 담당 범위: `apps/sn` 하수관망도 `분석도구`, popup registry, 지도 하이라이트, 결과 전용 popup 모드 +- 근거 문서 + - `COMMON_DOCS_ROOT/작업계획/SN-침입수분석결과-지도연계유지보수/001-de-SN-침입수분석결과-지도연계유지보수-설계.md` + - `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md` + - `FE_APP_DOCS_ROOT/기준문서/010-용어사전.md` + - `FE_APP_DOCS_ROOT/기준문서/013-UI레이아웃가이드.md` + +## 1-1. 공통 동기화 확인 + +- 공통 작업현황판 확인 여부: 확인 +- 파일점유현황 확인 여부: 확인 +- 결정로그 확인 여부: 확인 +- 기존 진행 중 작업과의 관계 + - `map/page.tsx`, `AnalysisPopupMenu.tsx`, `windowPopup/**`는 최근 지도형 popup 기능과 겹친다. + - 따라서 기존 `노후관로`, `관로분석`, `지반침하` popup 패턴을 그대로 재사용하는 방향을 우선한다. + +## 2. 작업 목적 + +- 기존 업무형 `ii/result` 화면의 침입수 분석결과 조회를 지도형 `분석도구` popup으로 옮긴다. +- 결과 행 선택 시 지도에서 결과 관로를 강조하고, `실측하수량` 보유 관로를 별도 색으로 구분한다. +- 하이라이트 관로 클릭 시 기본 관로 상세가 아니라 `침입수 분석결과` 전용 popup이 열리도록 FE 흐름을 전환한다. + +## 3. 수정 대상 파일 + +| 구분 | 파일 경로 | 수정 여부 | 메모 | +| --- | --- | --- | --- | +| 우측 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 완료 | `침입수 분석` 메뉴 추가 | +| 지도 메인 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 완료 | popup 열기, 결과 overlay, 클릭 분기, popup message 처리 | +| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 완료 | `inftwtResultPopup` 정의 추가 | +| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 완료 | 결과 popup content 연결 | +| 기존 결과 popup | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx` | 완료 | `resultOnly` 모드 추가 | +| 기존 API helper | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts` | 유지 | 기존 helper 재사용, popup content에서 고정 파라미터만 추가 | +| 기존 업무형 화면 | `FE_APP_ROOT/app/(with-base-layout)/ii/result/page.tsx` | 유지 | 현재 구현은 popup 전용으로 분리하고 기존 화면은 읽기 기준으로 유지 | +| 신규 popup | `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/**` | 완료 | 결과 목록 popup 화면 및 지도 연계 hook 구현 | + +## 4. 구현 범위 + +### 4.1 포함 + +- `분석도구 > 침입수 분석` popup 진입 +- 기존 `ii/result` 조회조건과 결과 목록의 popup 재구성 +- 결과 row click -> 부모창 message 전달 +- map/page 결과 overlay state와 focus 처리 +- 일반 결과 관로 / `실측하수량` 관로 이중 스타일 +- overlay 관로 click -> `infilChart` 결과 전용 모드 + +### 4.2 제외 또는 후속 + +- 실제 분석 산정 로직 수정 +- 신규 업무형 route 추가 +- 공통 패키지(`business_components`) 확장 +- 다른 앱 공통 popup 체계 변경 + +## 5. 화면 흐름 + +1. 사용자가 하수관망도 우측 `분석도구`에서 `침입수 분석`을 클릭한다. +2. 시스템이 `inftwtResultPopup`을 연다. +3. 사용자가 기존 침입수 분석 이력 목록을 조회한다. +4. 사용자가 특정 분석 row를 클릭한다. +5. popup은 부모창으로 `inftwtAnlsSn`과 row 요약을 전달한다. +6. `map/page.tsx`가 분석 결과 overlay를 그리고 focus area로 이동한다. +7. 사용자가 강조된 관로를 클릭한다. +8. 시스템은 `openWindowPopup("infilChart", { pplMngNo, inftwtAnlsSn, resultOnly: "Y" })`를 호출한다. +9. popup은 좌측 조건 없이 결과 그래프와 침입수량만 보여준다. + +## 6. FE 구현 메모 + +- popup UI는 `AgedPipePopupPage`, `PipeAnalysisPopupPage` 패턴을 우선 따른다. +- 새 popup에서 기존 `ii/result/page.tsx` 전체를 그대로 감싸기보다: + - 조회조건/테이블 데이터 조회 + - 다운로드 매핑 + - row 선택 로직 + - 이 3가지를 shared util 또는 hook으로 추출하는 쪽이 안전하다. +- overlay 관로는 기존 피처 클릭과 분리하기 위해 전용 vector layer를 우선 검토한다. +- `실측하수량` 관로 스타일은 `infilChart`의 주황 의미와 맞춘다. + +## 7. API 연동 + +| 기능 | Method | URL | 상태 | FE 메모 | +| --- | --- | --- | --- | --- | +| 분석이력 조회 | `GET` | `/api/v1/inftwtAnls` | 재사용 | popup 목록 조회 | +| 결과 전체 다운로드 | `GET` | `/api/v1/inftwtAnlsRslt/ppl/{inftwtAnlsSn}` | 재사용 | 엑셀 생성 source | +| 지도 연계 요약 | `GET` | `/api/v1/inftwtAnlsRslt/mapView/{inftwtAnlsSn}` | 신규 | focus geometry + highlight 대상 | +| 관로 결과 그래프 | `GET` | `/api/v1/inftwtAnlsRslt/ppl/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup | +| 실측하수량 시계열 | `GET` | `/api/v1/inftwtAnlsRslt/oilqPpl/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup | +| 침입수량 집계 | `GET` | `/api/v1/inftwtAnlsRslt/pplInftwtQty/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup | + +## 8. 검증 계획 + +- popup 진입/닫기 +- 기존 `ii/result`와 동일 조건 조회 결과 일치 +- row click 후 map focus 이동 +- 결과 관로 / 실측하수량 관로 스타일 구분 +- overlay click 시 기본 `FeatureClickEventPopup` 대신 결과 전용 popup 노출 +- `양평` 데이터에서 실측하수량 관로 색상 및 그래프 확인 + +## 9. 롤백 방법 + +- `AnalysisPopupMenu.tsx`의 `침입수 분석` 메뉴 제거 +- `page.tsx`의 결과 overlay state 및 message listener 제거 +- `infilChart.tsx`의 `resultOnly` 모드 제거 +- 신규 popup registry/definition 제거 + +## 10. 남은 리스크 + +- `ii/result`와 popup의 조회/다운로드 로직 공용화 범위를 잘못 잡으면 중복 수정이 생길 수 있다. +- 결과 관로 수가 많은 경우 overlay 피처 수와 클릭 성능을 점검해야 한다. +- 기본 하수관로 상세와 overlay click 분기 우선순위를 잘못 잡으면 사용자 체감이 어색해질 수 있다. + +## 11. 2026-07-01 유지보수 보정 + +- row click 후 FE가 기대하는 `mapView` 계약을 `focusLayerType`, `focusFid`, `focusFallbackApplied`, `pipeCount`, `oilqPipeCount` 중심으로 재정리했다. +- FE는 백엔드가 내려준 결과 관로 GeoJSON을 그대로 하이라이트하되, 지도 이동은 처리구역 1건의 `focus fid`를 우선 사용하고 실패 시 결과 관로 extent fallback만 수행한다. +- 이 보정으로 FE는 더 이상 전체 시계열 결과를 별도로 들고 있지 않고, 결과 popup row 요약 + 경량 mapView 응답만 기준으로 지도 연계를 수행한다. +- 브라우저 수동 검증 전까지 상태는 `검증`을 유지한다. + +## 12. 2026-07-01 row click 지도 이동 보정 + +- row click 직후 FE는 더 이상 `showSearchResultsFromGeoJSON`로 처리구역/관로를 강제 선택하지 않는다. +- `focusTargetType=ZONE`이면 처리구역 overlay extent를 우선 fit 하고, 아니면 결과 관로 extent를 fit 한다. +- 이 보정으로 잘못된 기본 관로 상세 패널이 자동으로 열리는 현상을 막고, 지도 이동과 overlay 하이라이트를 분리한다. + +## 13. 2026-07-01 분석 실행 화면 선택구조 유지보수 + +- `ii/irsetting`만 기존 시설 우선 selector를 그대로 쓰지 않고, 커스텀 `selectionComponent`로 처리구역 우선 selector를 주입한다. +- 새 selector는 시도/시군구 선택 뒤 `GET /api/v1/swagePrcsZones/selectionOptions`를 호출해 `하수처리구역명` 목록을 받고, 선택 시 `하수처리시설명`을 읽기 전용으로 자동 표시한다. +- `ii/rdii`, `ii/result`, 지도 검색 모달 등 기존 `CtpvSggPrcsFcltSelectBox` 사용처는 이번 범위에서 변경하지 않는다. +- 저장 payload는 기존과 동일하게 `swtrmtFcltNm`만 유지해 회귀 범위를 최소화한다. +## 14. 2026-07-09 분석 실행 화면 결과보기 유지보수 + +- `ii/irsetting` 우측 액션 영역에 `결과보기` 버튼을 추가한다. +- 버튼 클릭 시 분석 화면을 닫지 않고, 별도 `/map` 창과 `침입수 분석결과` popup을 함께 열어 기존 지도형 결과 흐름으로 바로 진입시킨다. +- popup row select는 기존 `window.opener.postMessage`를 유지하되, opener가 분석 화면일 수 있으므로 storage 이벤트 기반 보조 동기화를 함께 둔다. +- `map` 화면은 `analysisPopupKey` query를 받아 `분석도구` 활성 메뉴 상태를 `침입수 분석결과` 기준으로 맞춘다. + +## 15. 2026-07-10 전역 progress watcher / overlay 설계 + +- 현재 FE는 `ii/useAnalysisCompletionWatcher.ts`에서 완료 alert용 polling만 담당하므로, 같은 파일 또는 인접 컴포넌트로 역할을 아래처럼 확장한다. + - 분석 요청 등록 + - 상태 polling 갱신 + - 완료 alert 처리 + - 전역 progress overlay 렌더용 상태 제공 +- 실제 전역 mount는 `FE_APP_ROOT/src/app/providers/app-provider.tsx`에만 둔다. +- 구현 단계에서는 `FE_APP_ROOT/app/(with-base-layout)/layout.tsx`의 중복 `GlobalAnalysisCompletionWatcher` mount를 제거해 polling 2중 실행을 막는다. + +### 15.1 수정 대상 파일 보정 + +| 구분 | 파일 경로 | 수정 예정 | 메모 | +| --- | --- | --- | --- | +| 전역 provider | `FE_APP_ROOT/src/app/providers/app-provider.tsx` | 예정 | watcher / progress overlay 단일 mount | +| 레이아웃 정리 | `FE_APP_ROOT/app/(with-base-layout)/layout.tsx` | 예정 | 중복 watcher 제거 또는 전역 mount 정리 | +| 상태 watcher | `FE_APP_ROOT/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts` | 예정 | storage schema, progress polling, 완료/실패 종료 처리 확장 | +| 침입수 실행 화면 | `FE_APP_ROOT/app/(with-base-layout)/ii/irsetting/page.tsx` | 예정 | 접수 후 progress 감시 등록, full-screen 로딩 즉시 해제 유지 | +| 전역 overlay | `FE_APP_ROOT/app/(with-base-layout)/ii/**` 또는 `FE_APP_ROOT/src/app/providers/**` | 예정 | 좌측 하단 progress UI 신규 컴포넌트 | + +### 15.2 FE 상태 모델 + +- 기존 `SN_PENDING_ANALYSIS_WATCHES` storage key는 유지하되 item schema를 확장한다. +- 1차 침입수 분석용 item 초안 + - `analysisType: "INFTWT"` + - `analysisSn` + - `analysisName` + - `statusUrl` + - `progressPercent` + - `progressStep` + - `progressMessage` + - `updatedAt` + - `successMessage` + - `failureMessage` +- 추후 RDII 복제를 위해 `analysisType` 필드는 미리 포함한다. + +### 15.3 사용자 표시 규칙 + +- 접수 직후 alert 문구는 기존 사용자 요구대로 `분석 데이터가 성공적으로 제출되었습니다.`를 유지한다. +- 중앙 로딩 overlay는 접수 응답 직후 바로 닫는다. +- 전역 progress overlay는 좌측 하단에 고정하고 아래만 표시한다. + - 분석명 + - 진행률 바 + - 현재 단계 1줄 문구 +- 완료 후에는 overlay 안에서 별도 성공 메시지를 띄우지 않고 기존 alert만 사용한다. + +### 15.4 polling / 렌더링 규칙 + +- polling 주기는 현재 3초를 유지하되, status 응답이 바뀔 때마다 storage를 갱신해 overlay가 같은 데이터를 읽게 한다. +- `scsYn`이 `Y` 또는 `N`이면 해당 watch item을 제거하고 alert를 띄운다. +- 진행 중(`P`)이면 overlay만 갱신한다. +- `/map`, `ii/result`, `ii/irsetting`, 기타 `with-base-layout` 페이지에서 같은 storage를 읽으므로 페이지 이동과 무관하게 overlay가 유지된다. + +### 15.5 검증 포인트 보강 + +- `침입수분석 실행 -> 접수 alert -> 중앙 로딩 종료 -> 좌측 하단 progress 표시` +- `ii/irsetting -> ii/result -> /map` 이동 중 overlay 유지 +- 완료 시점 alert 수신 후 overlay 제거 +- 새로고침 또는 새 창 진입 시 storage에 남아 있는 진행 건을 다시 복구할지 여부는 구현 단계에서 확인하되, 1차는 같은 브라우저 세션 내 유지에 집중한다. diff --git a/apps/sn/docs/작업내용/FE-MAP-하수처리시설검색동기화/001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md b/apps/sn/docs/작업내용/FE-MAP-하수처리시설검색동기화/001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md new file mode 100644 index 000000000..cd4a3094c --- /dev/null +++ b/apps/sn/docs/작업내용/FE-MAP-하수처리시설검색동기화/001-fe-FE-MAP-하수처리시설검색동기화-구현기록.md @@ -0,0 +1,37 @@ +# FE-MAP 하수처리시설 검색 동기화 구현기록 + +## 1. 작업 목적 +- 지도 레이어 트리의 하수처리시설 500톤 기준 레이어 선택 상태와 상단 시설물 검색 조건을 동기화한다. + +## 2. 수정 대상 +- `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- `FE_REPO_ROOT/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx` +- `COMMON_DOCS_ROOT/DB작업.sql` + +## 3. 구현 내용 +- 하수처리시설 하위 레이어명은 DB `tb_sn_lyr_stng`의 `lyr_nm`을 그대로 사용하고, 프론트 강제 치환은 제거했다. +- 레이어 선택 상태에 따라 시설물 검색 셀렉트 문구를 `하수처리시설(전체)`, `하수처리시설(500톤 이상)`, `하수처리시설(500톤 미만)`, `하수처리시설(레이어 꺼짐)`으로 표시한다. +- 하수처리시설 검색 목록 조회 시 선택된 레이어 기준에 맞춰 `fclt_cpct >= 500` 또는 `fclt_cpct < 500` 조건을 함께 적용한다. +- WFS 목록 조회의 `cql_filter`는 `URLSearchParams`로 인코딩해 500톤 기준 조건이 실제 요청에 반영되도록 보정했다. +- 시설 선택 시 현재 지도 source만 검색하지 않고, feature id 기준 GeoServer 재조회 후 검색 레이어를 선택하도록 보정했다. +- 두 레이어가 모두 꺼진 경우 하수처리시설 목록은 선택 불가 placeholder만 표시한다. +- `tb_sn_lyr_stng`의 `lyr_sn=112/113` 레이어명을 `500톤 이상`, `500톤 미만`으로 정리하는 DML 초안을 `DB작업.sql`에 추가했다. + +## 4. 검증 +- TSX 파서 검사 통과 +- `pnpm --filter @common/business_components run build` 통과 + +## 5. 영향 범위와 리스크 +- 프론트 지도 화면의 레이어명 표시와 상단 시설물 검색 목록에 한정된다. +- API 계약과 DB 스키마 변경은 없다. +- DB 기준데이터 레이어명 변경 DML은 실제 대상 DB 반영 후 지도 레이어 트리에서 확인해야 한다. +- 레이어 메타데이터의 `flterSql/filtrSql` 또는 레이어명이 변경되면 500톤 기준 판별 조건을 함께 확인해야 한다. + +## 6. 2026-07-14 시도 단위 처리시설 목록 조회 보완 +- 작업 모드: `quick_vibe` +- 요구사항: 하수관망도 상단 검색바에서 시군구를 선택하지 않아도 선택한 시도의 하수처리시설 목록을 조회한다. +- 원인: 시설 목록 조회 조건과 WFS CQL이 시도·시군구 동시 선택을 전제로 해, 시도만 선택하면 목록을 초기화했다. +- 변경: 시도명은 필수 조건으로 유지하고 시군구명은 선택된 경우에만 CQL에 추가하며, 기존 500톤 이상·미만 필터는 그대로 조합한다. +- 검증: `pnpm --filter @common/business_components run build` 통과 +- 문서 판단: API·DB·공유 범위·공통 결정 변경이 없어 공통 문서는 갱신하지 않았다. +- 남은 검증: 실제 하수관망도에서 시도만 선택했을 때 시설 목록과 시설 선택 후 지도 포커스를 수동 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md new file mode 100644 index 000000000..2babe2395 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md @@ -0,0 +1,501 @@ +# SFR-010-010 지반침하정보 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-010-010 지반침하정보`의 FE 구현 이력, 설계 결정, 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 이번 턴은 구현 전 단계이므로 설계 검토 결과와 승인 필요 항목만 먼저 기록한다. +- 승인 후에는 새 파일을 추가하지 않고 이 문서 내부 헤더로만 구현 라운드를 누적한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/001-de-SFR-010-010-지반침하정보-설계.md` +- `COMMON_DOCS_ROOT/작업계획/SFR-010-010 지반침하정보/지반침하정보-화면구성시나리오_v1_1.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md` + +## 3. 공통 계약 + +- 프론트는 `reuse-service`를 직접 호출하지 않고 `sewerage-service`만 호출한다. +- 지도 포인트 좌표, 반경 내 관로 거리 계산, 좌표보정상태 판단은 백엔드 응답을 기준으로 처리한다. +- 목록 행 클릭 후 상세 패널은 목록행 데이터가 아니라 상세 API 응답으로 다시 맞춘다. +- 원천에 없는 상세 필드는 FE에서 `null -> "-"` 렌더링을 기본안으로 두되 승인 전까지 확정하지 않는다. +- 목록 팝업은 기존 `노후관로` 팝업 패턴, 우측 상세는 `FeatureClickEventPopup` 패턴 재사용을 우선한다. + +## 4. 라운드별 기록 + +### 4.1 2026-06-02 설계 착수 + +- 작업지시 폴더의 요구사항명세서, 시나리오, HTML, PNG를 읽고 기능 범위를 재해석했다. +- 백엔드 현재 작업트리에서 아래 선반영 코드를 확인했다. + - `GET /api/v1/sinkholes/geometry` + - IL datasource + - 주소 기반 지오코딩 서비스 +- 프론트 현재 코드에서는 `지반침하정보` 실제 기능 구현은 없고, HTML 샘플 및 레이어 명칭 흔적만 확인됐다. +- 이번 기능의 FE 핵심 축을 아래로 정리했다. + - 레이어 체크/해제 + - 목록 팝업 + - 우측 상세 패널 + - 반경 적용 UX + - 관리자 전용 동기화 버튼 노출 제어 +- 아직 구현하지 않은 항목 + - 실제 코드 수정 + - 브라우저 검증 + - API 연결 + +### 4.2 2026-06-05 좌표 응답 정합화 + +- 프론트 목록 팝업에 `좌표보정상태` 컬럼을 추가해 지오코딩 성공/실패를 운영 화면에서 바로 볼 수 있게 했다. +- 상세 패널에서 좌표가 없어 반경 내 관로를 조회하지 못하는 경우 `geocodeErrorMessage`를 함께 노출하도록 보강했다. +- 같은 시점의 로컬 백엔드 응답을 확인한 결과, `reuse-service` 지오코딩 응답의 추가 필드를 무시하지 못해 `Unrecognized field "adresTy"` 예외가 나고 있었음을 확인했다. +- 즉 프론트 좌표 표시 이상은 지도 상태관리 자체보다 백엔드 좌표 응답 실패가 선행 원인이었다. + +### 4.3 2026-06-05 구현 흐름 메모 + +- 지반침하 원천 데이터에는 사고 정보는 있는데 지도에 바로 표시할 좌표가 없는 경우가 있어서, 프론트는 백엔드가 좌표보정상태까지 붙여 내려주는 구조를 전제로 구현했다. + +예시데이터 + +- 사고번호: `20260024` +- 주소: `인천광역시 서구 백석동 74-11` +- 발생일자: `20260409` +- 복구상태: `복구중` +- `ocrn_txcrd`, `ocrn_tycrd`: 비어 있음 + +프론트에서 본 처리 흐름 + +- 목록 API에서 지반침하사고 1건을 받음 +- 백엔드가 원천 좌표 또는 주소 API 결과를 기준으로 `x`, `y`, `coordStatus`를 같이 내려줌 +- 프론트는 `coordStatus`가 성공이면 지도 포인트를 그림 +- 실패면 목록에서 `좌표보정상태 = 실패`로 보여주고 상세에서 실패 사유를 보여줌 + +### 4.4 2026-06-05 상세 패널 유지보수 + +- 지반침하 상세를 열었을 때 중심점과 반경 원은 그려지지만 줌이 그대로여서 사용자가 위치를 바로 확인하기 어렵다고 판단했다. +- 그래서 상세 조회 성공 시점과 반경 재적용 시점 모두 반경 원 extent 기준으로 `fit` 하도록 보강했다. +- `침하발생 반경내 관로정보` 표는 기존 단순 HTML 테이블에서 공용 `Table` 스타일로 바꿔 발생정보 카드와 더 비슷한 시각 톤을 맞췄다. +- 관로 ID 행을 클릭하면 `GisApp.Module.layer.showSearchResultsFromGeoJSON`로 `tb_ms_swage_ppl`의 `fid`를 넘겨 지도 강조를 요청하도록 연결해, 반경 내 관로를 상세 패널에서 바로 따라갈 수 있게 했다. + +### 4.5 2026-06-05 관로 강조와 상세 복귀 유지보수 + +- 관로 ID 클릭 시 현재 줌레벨은 유지한 채 해당 관로만 지도에 하이라이트하고, 관로 상세정보 창을 함께 열도록 수정했다. +- 관로 상세정보 창의 닫기 버튼을 누르면 선택된 관로 하이라이트를 해제하고, 직전에 보던 지반침하 상세 패널이 다시 보이도록 복귀 흐름을 추가했다. + +### 4.6 2026-06-05 레이어설정 기준 지반침하 트리 재정리 + +- 초기에는 프론트에서 `지반침하정보` 루트와 연도 노드를 임의로 만들었지만, 실제 `tb_sn_lyr_stng`에는 `지반침하정보` 루트만 있고 자식 노드가 없어서 도시침수지도와 같은 토글 구조로 보이지 않았다. +- 그래서 프론트는 실제 루트 row를 부모 토글로 사용하고, DB 자식 노드가 없을 때만 `현재년도`, `전체` 2개를 fallback으로 붙이도록 정리했다. +- 연도 노드 클릭 시에는 노드명 하드코딩 대신 `filter_sql`을 우선 읽어 목록 조회 조건을 만들고, `filter_sql`이 없을 때만 `sinkholeYear` 값을 fallback으로 사용하도록 수정했다. + +### 4.7 2026-06-09 연도/전체 노드 해석 유지보수 + +- `tb_sn_lyr_stng`에 `2026년`, `전체` 자식 노드를 실제로 넣은 뒤에는 프론트 fallback 노드가 아니라 DB 노드를 주로 타게 됐는데, 기존 `applySinkholeYearFilters`는 `sinkholeYear` 커스텀 필드만 읽고 있어 DB 자식 노드 선택을 해석하지 못하고 있었다. +- 그 결과 화면에는 `2026년`, `전체`가 보여도 초기 레이어 표출과 팝업 동기화에서는 기본 최근 3개월 조건이 남아 있을 수 있는 드리프트가 생겼다. +- 이번 유지보수에서 프론트는 `sinkholeYear`가 없는 경우에도 노드 텍스트 `2026년`, `전체`를 각각 `2026`, `ALL` 토큰으로 해석하도록 보강했다. +- 또한 `page.tsx`의 레이어 설정 초기 로딩에 지반침하 루트/자식 보정 유틸을 다시 연결해, DB 자식 노드가 있을 때와 없을 때 모두 같은 정리 경로를 거치도록 맞췄다. + +### 4.8 2026-06-09 발생기간 조회 시작일 2016년 확장 + +- 지반침하발생현황 팝업은 빈 조회조건으로 열릴 때 최근 3개월을 기본값으로 넣고 있었는데, 운영 확인용 장기 이력 조회에서는 시작일을 2016년까지 바로 내릴 수 있어야 한다는 요청이 생겼다. +- 프론트 기본 조회조건은 `2016-01-01 ~ 오늘`로 바꾸고, 날짜 입력 `min`도 `2016-01-01`로 고정했다. +- 이 변경만으로는 서버가 `최대 1년` 검증에서 막히므로, 백엔드 검증 규칙도 같은 기준으로 함께 맞춰야 한다고 정리했다. + +### 4.9 2026-06-09 다운로드 전체 결과 기준 재조회 유지보수 + +- 기존 다운로드 버튼은 현재 테이블에 렌더된 `rows`만 엑셀로 내보내고 있어, 사용자가 2페이지 이후 결과를 조회하지 않아도 “전체 조회 결과 다운로드”를 기대하는 UX와 어긋나고 있었다. +- 이번 수정으로 다운로드는 현재 조회조건을 그대로 유지한 채 `/api/v1/sinkholes`를 200건 단위로 다시 호출해 전체 결과를 합친 뒤 엑셀을 생성하도록 바꿨다. +- 엑셀 `NO` 컬럼도 현재 페이지 기준이 아니라 전체 결과 기준 1번부터 연속 번호가 매겨지도록 정리했다. + +### 4.10 2026-06-09 다운로드 체감속도 재검토 + +- 지반침하 다운로드는 기능적으로 전체 결과 기준으로 맞췄지만, 1600건 수준에서도 체감이 길다면 단순 페이지 수보다 백엔드 좌표보정 경로가 병목일 가능성이 높다고 판단했다. +- 프론트 재조회 배치 크기는 2000으로 올려 1600건 정도는 1회 목록 호출로 내려받게 조정했다. +- 이와 별개로 동일 주소의 지오코딩 실패도 30분 동안은 캐시되도록 백엔드 정책을 바꿔, 같은 다운로드를 반복할 때 외부 지오코딩을 매번 다시 치지 않게 정리했다. + +### 4.11 2026-06-09 좌표 실패 건 상세 fallback 표시 + +- 좌표변환 실패 건을 목록에서 클릭했을 때 상세 API가 generic 500으로 실패하면, 사용자는 사고 상세 자체를 전혀 열 수 없게 되는 문제가 있었다. +- 하지만 목록 행에는 사고번호, 발생일자, 주소, 복구상태, 좌표보정상태, 지오코딩 실패 사유가 이미 있으므로, 이 경우에는 목록 데이터를 우선 상세 패널 fallback 값으로 사용해도 UX가 성립한다고 판단했다. +- 그래서 목록 행 클릭 경로에서는 상세 API 실패 시 row 데이터를 `SinkholeDetail` 형태로 보강해 우측 패널을 열고, 반경 내 관로 조회가 실패하더라도 상세 자체는 계속 보여주도록 방어 로직을 추가했다. +- 이후 추가 정리로, 목록 row에 좌표가 없고 `coordStatus/geocodeStatus`가 이미 실패 상태인 경우에는 상세 API를 아예 호출하지 않고 곧바로 fallback 상세 패널을 여는 쪽으로 단순화했다. + +### 4.12 2026-06-09 연도 노드 지도범례 아이콘 표시 + +- 지반침하 `2026년`, `전체` 노드는 체크박스만 보이고 지도 범례 느낌이 약해, 하수처리시설처럼 노드 앞에 작은 아이콘을 두는 쪽이 더 직관적이라고 판단했다. +- `2026년`은 주황 원형, `전체`는 파랑 원형 SVG 아이콘을 `public/images/map/layericon/` 아래에 추가했고, DB 자식 노드와 FE fallback 노드 모두 같은 규칙으로 아이콘을 붙이도록 맞췄다. +- 공용 트리 렌더러는 기존에 `SN_SINKHOLE*` 전체를 아이콘 숨김 대상으로 보고 있었기 때문에, 지반침하 루트만 예외로 두고 연도 노드는 아이콘이 보이도록 분기 범위를 조정했다. +- 추가 확인 결과 공용 트리의 `makeLegendSrc()`가 `gobjTy`를 먼저 검사해 `iconNm`만 있는 노드를 바로 탈락시키고 있었고, 이 때문에 `icon_nm`을 넣어도 화면에 변화가 없었다. +- 그래서 공용 트리 렌더 순서를 `iconNm 우선 -> 없으면 gobjTy 기반 범례 생성`으로 바꿔, DB `icon_nm`과 FE 주입 `iconNm` 모두 실제로 보이게 맞췄다. + +### 4.13 2026-06-09 연도 노드 색과 지도 포인트 색상 일치 + +- 연도 노드 아이콘이 보여도 아이콘 중앙의 흰 점 때문에 지도 포인트와 시각 톤이 달라 보여, SVG를 단색 원형 + 진한 외곽선 형태로 다시 정리했다. +- 지도 포인트도 같은 색 규칙을 쓰도록 `createSinkholePointStyle`을 연도 기반 variant 구조로 바꿨다. +- `전체` 선택 시에는 `2026년` 건만 주황, 나머지 연도는 파랑으로 보이고, 선택 상세 포인트와 반경 원도 같은 색 계열을 따르도록 맞췄다. + +### 4.14 2026-06-09 포인트/아이콘 시각 농도 완화 + +- 실제 화면 확인 피드백 기준으로 지반침하 포인트가 너무 진해서 바탕지도를 가리고 있었고, 연도 노드 아이콘도 가운데가 빈 점처럼 보여 지도 포인트와 다르게 느껴졌다. +- 그래서 연도 아이콘 SVG는 외곽선과 중심 흰 점을 모두 제거한 꽉 찬 단색 점으로 단순화했다. +- 지도 포인트는 주황/파랑 모두 fill alpha와 stroke alpha를 낮추고 반경 원 투명도도 줄여, 기존보다 바탕지도가 더 잘 보이도록 완화했다. + +### 4.15 2026-06-09 포인트 농도 재보정 + +- 완화 이후 실제 체감이 지나치게 옅어져 포인트 존재감이 약해졌기 때문에, 일반 포인트 fill/stroke alpha와 반지름을 한 단계 다시 올렸다. +- 목표는 “예전 지반침하정보처럼 자연스럽게 보이되 지도 바탕을 과도하게 가리지 않는 중간 농도”로 두었다. + +### 4.17 2026-06-09 포인트 투명도 미세 상향 + +- 추가 피드백 기준으로는 아직도 포인트가 너무 연하게 느껴졌기 때문에, 일반 포인트 fill/stroke와 반경 원 alpha를 현재 값에서 절반 정도 더 보이는 수준으로 한 번 더 올렸다. +- 선택 포인트는 이미 충분히 강조되고 있어 선택 색은 유지하고, 일반 점과 반경 원의 가시성만 미세 조정했다. + +### 4.18 2026-06-09 목록/다운로드 조회조건 정리 + +- 추가 요구사항으로 지반침하 목록에 `좌표보정상태` 조회조건을 넣고, 다운로드는 `좌표보정상태` 열 자체를 제외한 채 현재 조회조건 전체 결과를 받아야 한다는 기준이 확정됐다. +- 프론트는 기본 발생기간을 다시 `현재 기준 최근 1년`으로 되돌렸고, 팝업 조회영역에 `좌표보정상태` select를 추가했다. +- 다운로드는 현재 조회조건을 유지하되 `includeCoordStatus=false`로 목록 API를 재조회해, 좌표보정상태 컬럼과 좌표해석 비용을 함께 줄이는 방향으로 정리했다. + +### 4.19 2026-06-09 팝업 닫힘 시 기본 최근 1년 복귀 + +- 기존에는 지반침하 팝업에서 마지막으로 조회한 조건이 `appliedSinkholeFilters`에 남아, 팝업을 닫아도 지도 레이어가 그 조건을 계속 유지하고 있었다. +- 추가 협의 기준으로는 팝업을 닫으면 다시 기본 최근 1년 조건으로 복귀하는 쪽이 더 자연스럽다고 보고, 팝업 close watcher를 붙였다. +- 이제 팝업이 닫히면 지도 지반침하 레이어 조회조건도 `buildSinkholeDefaultFilters()` 기준 최근 1년으로 즉시 되돌아간다. + +### 4.20 2026-06-09 초기 지도 표출 기준을 팝업 조건으로 단일화 + +- 기존에는 `appliedSinkholeFilters`와 별도로 `2026년`/`전체` 연도 자식 노드 체크 상태가 `applySinkholeYearFilters()`로 다시 날짜조건을 덮어쓰고 있어, 초기 로드 시 최근 1년 기본조건과 충돌했다. +- 그래서 지도 레이어 조회와 팝업 초기 전달에는 연도 노드 덮어쓰기를 제거하고, 팝업 조회조건 자체를 유일한 필터 기준으로 정리했다. +- 결과적으로 초기 로드, 팝업 조회 후 지도 반영, 팝업 닫힘 후 기본 최근 1년 복귀가 같은 필터 흐름으로 맞춰졌다. + +### 4.22 2026-06-09 팝업 활성 중 필터 우선순위 재정리 + +- 추가 확인 결과 사용자가 원하는 동작은 “평소에는 트리 연도 체크 상태 기준, 팝업이 열려 조회를 수행한 동안만 팝업 조건 우선, 팝업을 닫으면 다시 트리 기준 복귀”였다. +- 그래서 지반침하 레이어 조회에 `isSinkholePopupFilterActive` 상태를 추가하고, 팝업 sync 메시지를 받았을 때만 팝업 필터를 우선 사용하도록 조정했다. +- 팝업이 닫히거나 지반침하 레이어 자체를 끄면 popup-active 상태를 해제하고, 기본 최근 1년 필터를 기준으로 다시 트리 연도 조건이 적용되도록 정리했다. + +### 4.23 2026-06-09 목록보기 오픈 즉시 지도 동기화 + +- 추가 요구사항으로 `목록보기` 버튼을 눌러 팝업이 열리는 순간에도, 팝업이 들고 뜨는 초기 조회조건이 즉시 지도에 반영돼야 한다는 기준을 확인했다. +- 그래서 `openSinkholePopupWindow()`에서 팝업 오픈 직후 `isSinkholePopupFilterActive=true`, `appliedSinkholeFilters=effectiveFilters`를 바로 반영하고 selection version도 증가시키도록 연결했다. +- 이로써 팝업 내부 첫 조회 응답을 기다리지 않아도 지도와 팝업의 초기 조건이 먼저 맞춰진다. + +### 4.24 2026-06-09 트리 상위조건 + 팝업 하위조건 결합 + +- 추가 정리 결과 최종 UX는 `2026년`, `전체`를 상위 큰 조건으로 두고, 팝업 조회조건은 그 범위 안에서만 한 번 더 좁히는 구조가 가장 자연스럽다고 판단했다. +- 그래서 popup-active가 아닐 때는 `buildSinkholeTreeBaseFilters()`에 트리 연도 조건만 적용하고, popup-active일 때만 `팝업 조회조건 ∩ 트리 연도조건`을 계산하도록 로직을 바꿨다. +- 이로써 `2026년`만 체크 시 다른 연도는 나오지 않고, `전체` 체크 시에는 팝업 조건이 전체 범위 안에서만 적용되며, 팝업 종료 후에는 다시 트리 기준으로 복귀한다. + +### 4.25 2026-06-09 팝업 열린 상태에서 레이어 재체크 시 필터 유지 + +- 추가 확인 결과 팝업이 열린 상태에서 지반침하 레이어를 껐다가 다시 켜면, 레이어 해제 분기에서 popup-active 상태와 `appliedSinkholeFilters`를 같이 초기화하고 있어 팝업 조건이 사라지고 있었다. +- 이건 팝업 close 시점과 레이어 토글 시점을 혼동한 것이므로, 레이어 해제 시에는 popup-active와 팝업 필터를 건드리지 않고 레이어/상세만 닫도록 수정했다. +- 이제 팝업이 열린 상태에서 레이어를 다시 켜면 방금 보던 팝업 조건이 그대로 유지된 채 지도에 재적용된다. + +### 4.26 2026-06-09 기능 테스트 성공 반영 + +- 최종 사용자 확인 기준으로 현재 대화에서 다뤘던 지반침하 팝업/지도/다운로드 연동 이슈는 모두 기대 동작으로 수렴했다. +- 따라서 본 문서는 이번 라운드 기능 구현이 사용자 기능 테스트 기준 성공 상태임을 기준점으로 남긴다. + +### 4.16 2026-06-09 연도 노드 아이콘 외곽선 재조정 + +- 연도 노드 아이콘은 단색 점만 두었을 때 경계가 약해 보여, 사용자가 보여준 예시처럼 `색 원 + 흰 외곽선` 형태가 더 읽기 쉽다고 판단했다. +- 그래서 `2026년` 주황 아이콘과 `전체` 파랑 아이콘 모두 흰 외곽선을 다시 추가해 트리 안에서 더 또렷하게 보이도록 맞췄다. + +### 4.21 2026-06-09 조회옵션/다운로드 마감 정리 + +- `좌표보정상태` 조회조건은 최종적으로 `전체 / 성공 / 실패`만 남기고 `주소없음` 옵션은 제거했다. +- 다운로드 엑셀은 `좌표보정상태` 열을 제외한 9개 컬럼만 내리도록 유지했고, 헤더 범위도 `A1:I1`로 맞춰 첫 데이터 행이 헤더색을 먹지 않게 수정했다. + +### 4.27 2026-06-10 개발서버 SVG 아이콘 400 유지보수 + +- 로컬에서는 보였지만 개발서버에서는 `SN_SINKHOLE_SPOT_ORANGE.svg`, `SN_SINKHOLE_SPOT_BLUE.svg` 요청이 각각 `400 Bad Request`로 떨어지는 현상을 확인했다. +- 경로 생성은 기존 `Layer_Select_BTN.png`와 같은 `/images/map/layericon/` 규칙을 타고 있었고, 개발서버에서만 신규 `svg` 2건만 실패했으므로 이번 이슈는 트리 로직보다 개발서버의 정적 SVG 처리 차이로 판단했다. +- `layericon` 폴더의 기존 운영 자산이 대부분 `png` 기반인 점도 고려해, 연도 노드 아이콘 자산을 `png`로 추가하고 `sinkholeTypes.ts` 상수도 `.png`를 바라보도록 바꿨다. +- 이렇게 하면 DB 자식 노드와 FE fallback 노드 모두 기존 정적 아이콘 자산 체계와 같은 형식을 사용하므로, 서버 설정 차이 없이 같은 경로 규칙으로 안정적으로 로드할 수 있다. + +### 4.28 2026-06-11 목록 총 건수 헤더 보강 + +- 지반침하 목록 팝업은 기존에도 `/api/v1/sinkholes` 응답의 `total` 값을 상태로 보관하고 있었지만, `UiTable`의 `headName`을 비워 둔 상태라 노후관로처럼 상단에 “조회 결과 총 n건” 문구가 노출되지 않았다. +- 이번 유지보수에서는 `SinkholePopupPage.tsx`의 목록 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고, `isHideCount={true}`를 함께 적용해 `UiTable` 기본 건수 문구와 중복되지 않도록 정리했다. +- 결과적으로 노후관로 팝업과 같은 위치/형식으로 총 건수가 표시되며, 별도 API 변경 없이 기존 `total` 응답을 그대로 재사용한다. + +### 4.29 2026-06-11 시설물 트리 하단 설정영역 분리 + +- 기존에는 `지반침하정보`의 반경 설정 패널이 트리 노드 바로 아래 인라인으로 붙어 있었는데, 생활안전지도처럼 “레이어를 사용할 때만 하단 설정영역에 따로 모아 보여주는 구조” 요청이 추가됐다. +- 이번 유지보수에서는 패널 디자인과 입력/적용 동작은 그대로 두고, `map/page.tsx`에서 인라인 노출 분기를 제거한 뒤 `Gis`의 `layerSettingView` 하단 영역으로 이동시켰다. +- 따라서 `지반침하정보`를 체크하지 않으면 설정 패널이 보이지 않고, 연도/전체 노드가 활성화된 동안에만 트리 하단 별도 영역에 나타난다. +- 후속 보정으로 하단 영역 자체에 `선택 레이어 설정` 헤더와 선택된 레이어명 표시를 추가하고, 세로 공간이 부족할 때는 내부 스크롤이 생기도록 정리했다. +- `노후관로 설정`과 패널 폭이 달라 보이지 않도록 `지반침하정보` 설정 패널 폭도 하단 영역 기준 `w-full`로 통일했다. +- 추가 보정으로 별도 상단 타이틀 줄은 제거하고, 대신 시설물 레이어 목록 영역 자체를 얇은 직사각형 테두리로 감싸서 위 레이어 영역과 아래 설정영역이 자연스럽게 구분되도록 정리했다. +- 추가 점검으로 `선택 레이어 설정` 노출 기준을 연도 자식 노드 탐지 대신 실제 `isSinkholeChecked` 상태로 바꿔, 지반침하 레이어 체크 시 하단 설정영역 반영이 누락되지 않도록 보정했다. +- 공용 트리 접기 버튼이 세로 스크롤바 위를 덮어 보이는 문제를 줄이기 위해, 버튼을 패널 안쪽이 아니라 우측 바깥 경계 쪽으로 이동시켜 핸들처럼 보이도록 조정했다. +- 후속 조정으로 버튼이 아예 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다. +- 추가 수정으로 토글 버튼이 `overflow-hidden` 패널 박스에 같이 들어가 잘리던 구조를 분리해, 버튼을 패널 박스 바깥 형제 위치로 옮겨 실제 화면에서 보이도록 보정했다. +- 최종 보정으로 토글 버튼 시각 스타일은 분석도구 패널에서 사용 중인 `36x20` 핸들 디자인과 동일하게 맞추고, 위치는 패널 바깥 중앙을 유지하도록 정리했다. +- 추가 구조 보정으로 시설물 레이어 직사각형 박스는 화면에 항상 보이는 고정 영역으로 두고, 실제 롤업/스크롤은 그 내부에서만 일어나도록 바꿨다. 토글 핸들도 이제 패널 전체가 아니라 이 직사각형 박스 기준 중앙에 붙는다. +- 후속 위치 보정으로 토글 핸들이 박스 바깥으로 너무 멀리 빠지지 않도록 `left-full` 배치를 제거하고, 레이어 직사각형 박스 오른쪽 경계에 걸치는 `right` 음수 오프셋 기준으로 다시 맞췄다. +- 후속 가시성 보정으로 토글 핸들 크기를 키우고 파란 계열 대비와 그림자를 강화해, 배경 지도와 겹쳐도 버튼이 바로 보이도록 조정했다. +- 추가 구조 보정으로 생활안전지도처럼 `레이어 박스 내부 스크롤`과 `패널 전체 스크롤`을 분리했다. 시설물 레이어 박스는 고정 높이와 고정 테두리를 유지하고, 선택 레이어 설정이 많아질 때는 패널 전체 스크롤로 아래 내용을 보도록 정리했다. +- `침수위험지도(중점관리지역)`, `도시침수지도(정보제공포털)` 선택 시 `침수심 범례`는 토글 제어가 아니라 선택 레이어 설정 안에서 실제 범례 항목을 그대로 보여주는 정보 카드로 정리했다. +- 스크롤바 폭은 줄이되 색상은 브라우저 기본색으로 되돌려, 이전보다 얇지만 과한 커스텀 색은 보이지 않도록 맞췄다. +- 후속 정리로 선택 레이어 설정 안의 침수심 범례 카드는 실제 범례 본문만 남기고, 지도 우측 하단에 별도로 뜨던 기존 범례 박스는 제거했다. +- 추가 구조 보정으로 토글 핸들을 패널 이동 영역과 완전히 분리된 형제로 빼고, 열림 시에는 레이어 박스 옆에, 닫힘 시에는 화면 왼쪽 가장자리에 남도록 바꿔 다시 열기 버튼이 사라지지 않게 정리했다. + +### 4.30 2026-06-24 개발서버 PNG 아이콘 400 재점검 + +- 개발서버에서 `SN_SINKHOLE_SPOT_ORANGE.png`, `SN_SINKHOLE_SPOT_BLUE.png`만 `400 Bad Request`로 실패하고, 없는 파일은 `404`, 기존 `layericon` PNG는 `200`으로 내려오는 차이를 재확인했다. +- 코드의 `/images/map/layericon/` 경로 조합은 기존 PNG와 같으므로, 아이콘명을 다른 자산으로 바꾸지 않고 두 PNG의 저장 구조를 비교했다. +- 문제 PNG는 `sRGB`, `gAMA`, `pHYs` 부가 청크를 포함하고 있었고, 정상 로드되는 단순 RGBA PNG 중 `WTL_RSRV_PS_ORANGE.png`는 `IHDR`, `IDAT`, `IEND`만 가지고 있었다. +- 이에 `SN_SINKHOLE_SPOT_ORANGE.png`, `SN_SINKHOLE_SPOT_BLUE.png`의 파일명과 픽셀은 유지하고, PNG 컨테이너만 `IHDR + IDAT + IEND` 구조로 재인코딩해 개발서버 정적 파일 처리 차이를 줄였다. + +### 4.31 2026-06-25 반경 내 관로정보 경과년도 컬럼 및 요약문구 추가 + +- 요구사항 변경으로 관로 `용도` 컬럼은 이번 범위에서 제외하고, 지반침하 상세 패널의 `침하발생 반경내 관로정보` 표에 `경과년도` 컬럼만 추가했다. +- 표는 `NO / 관로 ID / 경과년도 / 거리(m)` 4개 컬럼으로 구성하고, 기존 제목은 유지하고 제목 아래에 요약문구 한 줄을 추가해 `건수`, `평균경과`, `최장경과` 값을 파란색 볼드로 표시한다. +- 집계값은 `nearbyPipes` 응답의 `elapsedYears` 중 숫자로 해석 가능한 값만 대상으로 계산하며, 계산 대상이 없으면 기존 표시 정책대로 `-`를 보여준다. +- 개별 행의 `경과년도`는 백엔드 `nearbyPipes` 응답에 추가한 `elapsedYears`를 사용한다. + +### 4.32 2026-06-25 반경 조회 흐름 원복 확인 + +- 기존 반경 내 관로 조회 기능은 정상 동작하던 기능이므로, `map/page.tsx`에 추가했던 좌표/사고번호 재조회 fallback 변경은 제거했다. +- FE 변경 범위는 `SinkholeDetailPanel.tsx`의 표 컬럼 추가와 `sinkholeTypes.ts`의 `elapsedYears` 타입 추가로 제한한다. + +### 4.33 2026-06-26 레이어 연도범위 설정 전환 사전 분석 + +- 이번 요청은 구현 전 분석 단계로 한정하며, 실제 FE/BE 코드는 수정하지 않았다. +- 요구사항은 기존 `2026년`, `전체` 자식 레이어를 화면에서 제거하고, `지반침하정보` 루트 레이어만 on/off 하도록 단순화하는 것이다. +- 현재 하단 `선택 레이어 설정`의 `SinkholeLayerSettingPanel`에는 `지반침하반경(m)`만 있으므로, 다음 구현에서는 같은 패널에 `시작년도`, `종료년도` 입력과 적용 버튼을 추가한다. +- 기본 연도 범위는 현재 연도 기준 최근 1년으로 해석한다. 현재 연도가 2026년이면 화면 입력값은 `2025 ~ 2026`이고, API 호출값은 `startYmd=20250101`, `endYmd=20261231`로 변환한다. +- 현재 코드에서 정리해야 할 지점은 아래와 같다. + - `sinkholeTypes.ts`: `buildSinkholeYearNodes`, `isSinkholeYearNode`, `applySinkholeYearFilters`, 연도 노드 아이콘 상수의 사용 범위 축소 또는 제거 + - `page.tsx`: `appendSinkholeControlNode`의 연도 fallback 추가 제거, `applySinkholeYearFilters` 호출 제거, 팝업 close 시 기본 필터 복귀 기준 재정의 + - `SinkholeLayerSettingPanel.tsx`: 연도 범위 입력 상태와 검증, 적용 callback 추가 + - `DB작업.sql`: `2026년`, `전체` 자식 레이어 추가/보정 DML 제거 또는 기존 행 삭제 DML로 전환 +- BE는 이미 `startYmd/endYmd` 목록 조회 파라미터와 최소일 검증을 제공하므로, 이번 요구는 현재 분석 기준으로 FE/DB 기준정보 변경만으로 처리 가능하다. +- 팝업 조회조건과 설정패널 연도 범위의 관계는 다음 구현에서 명확히 고정해야 한다. 우선안은 설정패널 연도 범위를 지도 레이어 기본 조건으로 두고, 팝업은 그 조건을 초기값으로 받아 목록 조회를 시작하는 방식이다. + + +### 4.34 2026-06-26 레이어 연도범위 설정 전환 구현 + +- `sinkholeTypes.ts`에서 `2026년`/`전체` 합성 노드와 연도 노드 기반 필터 결합 로직을 제거하고, `SinkholeYearRange` 및 시작/종료년도 기반 필터 변환 유틸을 추가했다. +- `page.tsx`는 지반침하정보 트리 노출을 루트 on/off로 단순화하고, 지도 조회/팝업 동기화는 선택 레이어 설정의 연도 범위에서 만든 `startYmd/endYmd`를 사용하도록 변경했다. +- `SinkholeLayerSettingPanel.tsx`에 시작년도/종료년도 입력을 추가하고 적용 시 연도 유효성 검증 후 지도 레이어를 다시 조회하도록 연결했다. +- 기본 연도 범위는 현재년도 기준 최근 1년이다. 2026년 기준 화면 기본값은 `2025 ~ 2026`이며 조회 파라미터는 `20250101 ~ 20261231`로 변환된다. +- 기존 반경 설정, 상세 패널 반경 내 관로 조회, 목록 팝업 row 선택 흐름은 유지했다. + + + +### 4.35 2026-06-26 상세 500 fallback 및 설정 패널 UI 보정 + +- 지도 포인트 선택 시 `loadSinkholeDetail`에 feature/list 응답 기반 fallback 상세정보를 함께 전달하도록 변경했다. 상세 API가 500을 반환해도 목록 응답에 있는 주소, 발생일자, 좌표, 복구상태 등으로 상세 패널을 계속 열 수 있다. +- 지반침하반경 설정은 선택 지점이 없어도 전역 값으로 저장되며, 선택 지점이 있을 때만 반경 내 관로를 즉시 재조회한다. +- 선택 지점 좌표가 있는 경우 반경 내 관로 재조회는 `nearbyPipesByPoint`를 우선 사용해 사고번호 기반 주변 관로 API 실패 영향을 줄였다. +- 선택 레이어 설정 패널의 시작/종료년도 줄은 기존 반경 입력 줄과 같은 버튼 크기와 입력 높이, 간격으로 정렬했다. + + + +### 4.36 2026-06-26 지반침하 포인트 색상 단일화 + +- 지반침하 포인트가 발생연도 기준으로 현재년도는 주황색, 그 외 연도는 파란색으로 표시되던 분기 로직을 제거했다. +- `resolveSinkholePointVariant`는 발생일자를 받아도 항상 `blue`를 반환하도록 변경해, 2026년 데이터와 그 외 데이터가 모두 동일한 파란색 원형 포인트로 표시된다. +- 선택 지점 포인트와 반경 표시 기본 스타일도 파란색 기준으로 맞췄다. + +### 4.37 2026-06-26 선택 레이어 설정 안내문구 및 범례 높이 미세조정 + +- 지반침하 반경 적용 alert는 적용된 반경 값을 포함해 `지반침하반경은 {값}m로 반영되었습니다.`로 표시한다. +- 지반침하 시작/종료년도 적용 alert는 적용된 연도 범위를 포함해 `시작년도 {시작년도} ~ 종료년도 {종료년도} 반영되었습니다.`로 표시한다. +- 노후관로 경과년도 적용 alert는 적용된 경과년 값을 포함해 `노후관로 경과년도 {값}년으로 반영되었습니다.`로 표시한다. +- 침수심 범례는 내부 디자인은 유지하고 외곽 세로 여백만 줄여 선택 레이어 설정 영역의 높이를 소폭 낮췄다. + +### 4.38 2026-06-26 우측 상세 패널 공통 탭 UX 설계 + +- 사용자 요구사항을 재해석해, 이번 보완은 `SinkholeDetailPanel`에만 탭을 붙이는 작업이 아니라 `/map` 오른쪽 상세 패널 자리에 열리는 모든 상세 컴포넌트를 공통 탭으로 관리하는 UX 개선으로 정리했다. +- 현재 지반침하 상세에서 관로 ID를 클릭하면 `FeatureClickEventPopup`으로 하수관로 상세가 열리고, `restoreSinkholeDetailOnCloseRef`를 통해 닫기 시 지반침하 상세로 복귀한다. 이 숨은 복귀 흐름이 사용자에게 혼란을 줄 수 있으므로, 열린 상세를 책갈피 탭으로 명시하는 방향을 채택한다. +- 1개 상세만 열렸을 때는 기존과 동일하게 탭을 숨기고, 2개 이상일 때만 오른쪽 패널 왼쪽 가장자리에 세로 책갈피 탭을 표시한다. +- 탭 관리 대상은 우선 `FeatureClickEventPopup`과 `SinkholeDetailPanel`로 두고, 편집 세션처럼 저장 전 입력값 손실 위험이 있는 팝업은 구현 단계에서 포함 여부를 별도 판단한다. +- 다음 구현에서는 `page.tsx`에 `rightPanelTabs`, `activeRightPanelTabId` 상태와 `openRightPanelTab`, `closeRightPanelTab`, `activateRightPanelTab` helper를 추가하는 방향을 우선 검토한다. +- 기존 `featureData`/`selectedSinkholeDetail` 단일 패널 흐름은 한 번에 제거하지 않고, 탭 활성 payload 기반 렌더러로 감싸 회귀 위험을 줄인다. +- 같은 상세 대상은 중복 탭을 만들지 않고 기존 탭을 활성화한다. 예: 같은 `tb_ms_swage_ppl.fid` 관로를 다시 클릭하면 기존 하수관로 탭으로 이동한다. +- 이번 설계는 FE 상태관리와 UX 변경이며, BE API나 DB 계약 변경은 필요하지 않다. +### 4.39 2026-06-26 우측 상세 패널 공통 탭 UX 구현 + +- `page.tsx`에 `RightPanelTab`, `RightPanelBookmarkTabs`를 추가해 오른쪽 상세 패널에 열린 상세들을 탭 단위로 관리하도록 구현했다. +- 탭이 1개일 때는 기존 화면처럼 책갈피를 숨기고, 2개 이상일 때만 오른쪽 패널 왼쪽 가장자리에 세로 책갈피 탭을 표시한다. +- `featureData`, `selectedSinkholeDetail`, `gisEditDetailChangeId`를 기준으로 탭 id/title/payload를 만들고, 같은 피처나 같은 지반침하 사고번호를 다시 열면 기존 탭을 활성화한다. +- 기존 `GisEditAwareDetailView`는 `props.featureData`만 보던 구조에서 활성 탭 payload를 우선 사용하는 구조로 변경했다. +- `FeatureClickEventPopup`, `SinkholeDetailPanel`, `GisEditPipeSessionPopup` 내부 레이아웃은 유지하고, `X` 버튼은 현재 활성 탭 닫기로 연결했다. +- 지반침하 반경 내 관로 클릭 경로에서 기존 `restoreSinkholeDetailOnCloseRef` 복귀 플래그 의존을 제거하고, 지반침하/하수관로 탭을 병렬로 유지하도록 전환했다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. + +### 4.40 2026-06-26 우측 상세 패널 탭 식별값 보강 + +- 책갈피 탭이 여러 개 표시될 때 사용자가 같은 종류의 상세를 구분하기 쉽도록 탭 내부에 보조 식별값을 추가했다. +- 지반침하 탭은 제목 아래에 `사고번호`를 표시하고, 하수관로 탭은 제목 아래에 `관리번호`를 표시한다. +- 보조 식별값이 없는 상세 탭은 기존처럼 제목만 표시해 불필요한 빈 줄이 생기지 않도록 했다. +- 탭 폭과 최소 높이를 소폭 넓혀 긴 숫자가 줄바꿈되더라도 기존 오른쪽 상세 패널을 가리지 않는 범위에서 읽히도록 조정했다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. +### 4.41 2026-06-29 우측 상세 패널 탭 최대 개수 제한 + +- 책갈피 탭이 과도하게 쌓여 오른쪽 상세 패널 주변을 가리는 문제를 줄이기 위해 우측 상세 패널 탭 최대 개수를 5개로 제한했다. +- 5개가 열린 상태에서 새 상세를 추가하면 새 탭은 정상적으로 열고, 현재 활성 탭은 유지하며, 나머지 중 가장 오래 사용하지 않은 일반 상세 탭을 우선 제거한다. +- 지반침하 탭은 관로 탐색의 기준점이 될 수 있으므로 일반 하수관로 탭보다 늦게 제거되도록 했다. +- GIS 편집 상세 탭은 저장 전 입력값 손실 가능성이 있어 자동 제거 후보의 우선순위를 낮췄다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. +### 4.42 2026-06-29 우측 상세 패널 탭 전환 지도 선택 동기화 + +- 책갈피 탭을 클릭해 활성 상세를 바꿀 때 오른쪽 패널 내용만 바뀌고 지도상의 관로 강조가 이전 선택에 남아 있던 문제를 보정했다. +- 우측 상세 탭 전환 시 활성 탭의 feature payload를 기준으로 지도 선택 상태와 지반침하 관로 강조 레이어를 함께 갱신하도록 `syncRightPanelFeatureSelectionOnMap` 경로를 추가했다. +- 하수관로 탭으로 이동하면 해당 관로 feature를 `SN_SINKHOLE_FOCUSED_PIPE_LAYER`에 다시 그리고 선택 상태로 맞춘다. +- 지반침하 탭으로 이동하면 관로 강조 레이어를 지우고 지반침하 중심점/반경 표시만 남도록 했다. +- 반경 내 관로 ID 최초 클릭 경로도 같은 지도 동기화 함수를 사용하도록 정리해, 최초 클릭과 탭 전환의 지도 선택 동작이 같은 규칙을 따른다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. +### 4.43 2026-06-29 지도 직접 선택 시 지반침하 관로 포커스 해제 + +- 지반침하 상세의 반경 내 관로 ID 클릭으로 표시한 파란 관로 포커스가 남아 있는 상태에서 사용자가 지도상의 다른 관로를 직접 클릭하면, 기존 파란 포커스가 함께 남는 문제가 있었다. +- 일반 지도 피처 선택 진입점에서 `clearSinkholeFocusedPipeLayer()`를 먼저 호출하도록 보정해, 사용자가 직접 선택한 보라색 선택선만 남도록 했다. +- 반경 내 관로 ID 클릭과 책갈피 탭 전환은 기존처럼 `syncRightPanelFeatureSelectionOnMap`을 통해 필요한 관로 포커스를 다시 그린다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. +### 4.45 2026-06-29 우측 상세 책갈피 탭 비활성 글자색 조정 + +- 사용자 확인 기준으로 우측 상세 책갈피 탭의 비활성 글자색이 파란색이라 현재 지도/패널 톤에서 과하게 튄다는 피드백이 들어왔다. +- `page.tsx`의 `RightPanelBookmarkTabs` 비활성 상태 클래스를 `text-[#1d4ed8]`에서 `text-slate-900`으로 바꿔, 활성 탭만 청록 헤더 강조를 받고 비활성 탭은 검은색 계열 텍스트로 보이도록 정리했다. +- 테두리와 hover 배경은 기존 값을 유지해, 탭 상태 구분은 남기되 색 대비만 낮추는 방향으로 조정했다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 1개이며, BE API/DTO/DB 변경은 없다. + +### 4.46 2026-06-29 지반침하 연도설정 선택형 UI 및 한 줄 레이아웃 보정 + +- 추가 요구사항으로 지반침하 하단 설정영역의 시작/종료년도 직접 입력 대신 프로젝트 기존 선택형 UI 패턴으로 바꿔 달라는 요청이 들어왔다. +- `SinkholeLayerSettingPanel.tsx`에서 연도 범위를 숫자 `input` 2개 대신 `SelectBox` 2개로 바꾸고, 선택 가능한 연도는 `SINKHOLE_MIN_SEARCH_YEAR`부터 현재 연도까지로 제한했다. +- 시작 연도는 현재 종료 연도 이하만, 종료 연도는 현재 시작 연도 이상만 보이도록 옵션을 좁혀 기존 검증 규칙을 입력 단계에서도 미리 반영했다. +- 이어서 compact 패널이 너무 좁아 보인다는 피드백에 맞춰 연도 셀렉트 폭을 넓히고, `종료년도` 라벨을 제거해 `년도설정 [시작연도] ~ [종료연도] [적용]` 한 줄 구성을 유지하도록 다시 다듬었다. +- 최종 라벨은 사용자 요청대로 `시작년도` 대신 `년도설정`으로 바꿨고, 카드 내부 좌우 여백과 행 간격도 소폭 늘려 답답한 인상을 줄였다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx` 1개이며, BE API/DTO/DB 변경은 없다. +- 이번 라운드는 브라우저 실화면 수동 검증까지는 진행하지 못했고, 코드 diff 기준으로만 반영 상태를 확인했다. + +## 5. 승인 전 보류 사항 + +- 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요 +- 최근 동기화 일시를 관리자에게 어디에 보여줄지 확정 필요 +- 원천 부재 필드를 FE에서 `-`로 고정해도 되는지 승인 필요 +- 좌표 캐시를 메모리만 쓸지 DB 영속화까지 포함할지 BE 승인 필요 + +## 6. 남은 리스크 + +- `map/page.tsx`는 기존 기능이 많아 무리하게 누적하면 회귀 위험이 크다. +- 원천 데이터에 없는 컬럼을 화면은 요구하고 있어, API/FE 표시 정책이 늦게 확정되면 구현이 되돌아갈 수 있다. +- 좌표 없는 사고건과 좌표 실패 사고건은 UI와 QA 시나리오가 달라 예외 흐름이 많다. + +### 4.44 2026-06-29 지반침하 상세 전용 OGC011 공통코드 매핑으로 범위 제한 + +- 지반침하 상세의 반경 내 관로 `용도`만 `OGC011` 공통코드로 한글화하도록 범위를 다시 좁혔다. +- 초기 보완에서는 `searchCmmnCd` 공통 헬퍼를 확장했지만, 다른 기능으로 영향 범위가 번질 수 있어 공통 헬퍼 변경은 되돌렸다. +- 대신 `SinkholeDetailPanel.tsx` 내부에서만 `getCmsData('/api/v1/code/cache/OGC011/Y')`를 우선 조회하고, 실패 시 `OGC011` 전체 조회로 fallback 하도록 로컬 캐시 로더를 추가했다. +- 이로써 현재 `sn`에서 공통코드 확장 영향은 지반침하 상세 패널 내부에만 한정되고, 기존 다른 화면의 공통코드/하드코딩 코드표 흐름은 건드리지 않는다. +- 표 컬럼 폭도 함께 조정해 `관로 ID`는 소폭 줄이고 `용도`는 한 줄로 보이도록 미세 보정했다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx`, `FE_APP_ROOT/app/components/lib/common/apiHelpers.ts` 2개이며, 공통 헬퍼는 원래 계약으로 복구했다. +- BE API, DTO, DB 변경은 없다. + +### 4.47 2026-07-02 사고번호 기반 좌표 원천화 설계 분석 + +- 사용자 요구사항은 현재 주소 기반 실시간 좌표보정 대신, 사고번호 기반 외부 조회 결과를 원천 `ocrn_txcrd`, `ocrn_tycrd`에 영속화하는 구조로 바꾸자는 것이다. +- 현재 FE 구현은 목록/상세/반경 조회 모두 백엔드가 내려주는 `coordStatus`, `latitude`, `longitude`, `x`, `y` 계약만 소비하므로, 좌표 원천 전략이 바뀌어도 같은 DTO를 유지하면 FE 코드는 1차 변경이 필요하지 않다. +- 이번 분석에서 확인한 현행 FE 포인트는 아래와 같다. + - 목록은 별도 윈도우 팝업 `SinkholePopupPage.tsx`에서 `/api/v1/sinkholes`를 호출한다. + - 지도 레이어도 `page.tsx`에서 같은 목록 API 응답을 GeoJSON feature로 재조립해 사용한다. + - 상세/반경 조회는 상세 API와 `nearbyPipesByPoint` 우선 fallback 구조를 사용한다. +- 따라서 이번 설계 변경의 FE 리스크는 “새 좌표 취득 방식” 그 자체보다 “BE가 응답 필드와 상태값을 바꾸는지 여부”에 집중된다. +- FE 후속 구현은 다음 조건이 생길 때만 고려한다. + - `coordStatus` 값 정의 변경 + - 관리자 동기화 결과 문구/요약 구조 변경 + - 좌표 실패 안내문을 JIS 사고번호 조회 기준으로 재정비해야 하는 경우 + +### 4.48 2026-07-02 사고번호 기반 좌표 원천화 구현 라운드 FE 영향 재확인 + +- 이번 구현 라운드에서는 FE 코드 수정 없이 대응했다. +- 백엔드가 기존 계약인 `coordStatus`, `latitude`, `longitude`, `x`, `y`를 유지하도록 구현했기 때문에, 현행 `SinkholePopupPage`, `SinkholeDetailPanel`, `page.tsx`는 그대로 동작해야 한다는 판단을 유지한다. +- 따라서 이번 라운드의 FE 검증 포인트는 “화면 코드 변경”이 아니라 “기존 화면이 새 백엔드 응답 경로에서도 회귀 없이 동작하는지”에 집중한다. + +### 4.49 2026-07-02 잔여 실패 건에 대한 FE 관점 이슈 메모 + +- 사용자 확인 기준 JIS 성공 케이스 `20260040`은 상세 페이지 HTML에 `poly_lonlat=POINT(...)`가 존재하는데도, 홈페이지 `위치 확인` 버튼 클릭 시 `mapNew.js:524 ... clone` 오류가 발생할 수 있었다. +- 이 오류는 JIS 프론트 지도 이동 스크립트 문제로 보이며, 우리 FE가 받는 백엔드 DTO 실패와는 직접 원인이 다르다. +- 실제 잔여 실패 159건은 대부분 JIS 구 사고번호 응답 자체가 `fail`이거나 주소 fallback도 실패하는 케이스로 확인됐기 때문에, 현재 FE 수정 포인트는 없다. +- FE 후속 액션은 백엔드가 `JIS 우선 + 주소 fallback`을 최종 운영안으로 채택할 때도 기존 `coordStatus`, `geocodeErrorMessage` 표시만 회귀 없이 유지되는지 확인하는 정도다. +### 4.50 2026-07-13 우측 상세 탭 유형·단일 관로·부모 복귀 구현 + +- `RightPanelTab`에 렌더링 종류와 별개인 `category`, 사용자 표시용 `displayLabel`, 관로의 `parentTabId`를 추가했다. +- 지반침하, 관로, 처리시설, 처리구역, GIS 편집, 기타 피처별 정적 Tailwind class map을 두어 활성/비활성 색상을 구분했다. +- 기존 제목/보조값 2줄 구조를 괄호형 단일 표시명으로 바꾸고, 처리시설명·처리구역명 후보도 OpenLayers Feature/plain object 양쪽에서 읽도록 통합했다. +- 지반침하 탭 ID는 사고번호별로 유지하고, 관로 탭 ID는 `feature:tb_ms_swage_ppl:singleton`으로 고정해 마지막 선택 관로로 교체 갱신한다. +- 반경 내 관로 클릭 시 현재 활성 지반침하 탭을 부모로 기록하고, 부모 탭이 남아 있을 때만 `관로(관리번호)` 표시명 바로 옆에 20px 소형 `←` 버튼을 표시한다. +- 화살표 클릭은 관로 탭을 유지한 채 부모 지반침하 탭을 활성화하고 지도 반경/선택 동기화 경로를 재사용한다. +- 관로 탭 닫기는 부모 지반침하를 MRU보다 우선하고, 부모 탭 닫기는 남은 관로의 부모 링크를 제거한다. +- 최대 5개 자동 정리 시 활성 관로 탭의 유효한 부모는 제거 후보에서 보호한다. +- 지도에서 관로를 직접 선택하면 부모 ref를 비워 이전 지반침하 복귀 링크가 재사용되지 않게 했다. +- 읽히지 않던 `restoreSinkholeDetailOnCloseRef`는 제거했다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 1개이며 BE API·DTO·Repository·DB 변경은 없다. + +### 4.51 2026-07-14 부모 복귀 화살표 및 처리구역 API 후속 유지보수 + +- 관로 탭 내부의 부모 복귀 화살표를 20px에서 24px로 키워 현재 배치 안에서 시인성과 클릭 영역을 소폭 확대했다. +- 화살표 표시 조건에 `isActive`를 추가했다. 따라서 부모가 연결된 관로 탭을 보고 있을 때만 화살표가 보이고, 화살표를 눌러 부모 지반침하 탭이 활성화되면 관로 탭의 화살표는 숨겨진다. +- 최대 5개 탭의 overflow 로직을 재확인했다. 현재 정책은 FIFO가 아니라 신규/활성/관로 부모 탭을 보호하고 `lastActiveAt`이 오래된 일반 feature 탭을 우선 제거하는 보호형 LRU다. +- 하수처리구역 클릭 500 오류의 FE 호출 경로는 변경하지 않았다. `swtrmtPrcsZoneAnalysisApi.ts`의 기존 API 계약은 정상이며, 삭제된 백엔드 Controller/Service를 복구해 계약을 다시 연결했다. +- 프론트 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 1개다. +- DB 변경은 없다. + +### 4.52 2026-07-14 현재 완료 범위 및 후속 탭 정책 메모 + +- 현재 완료 범위는 유형별 색상·괄호형 탭 이름, 지반침하 다중 탭, 관로 singleton, 부모 지반침하 복귀, 활성 관로에서만 보이는 24px 화살표, 최대 5개 보호형 LRU 정리다. +- 처리시설과 처리구역은 현재 피처별 다중 탭으로 동작한다. +- 후속 운영 결과에 따라 처리시설 1개와 처리구역 1개를 각각 singleton으로 바꾸는 안을 검토한다. +- 정책 확정 전에는 현행 동작을 유지하며 이번 라운드에서 추가 코드 변경은 하지 않았다. + +### 4.53 2026-07-14 반경 라벨·트리 심볼·지도 선택 해제·목록 사고번호 유지보수 설계 + +- 현행 구현을 기준으로 유지보수 변경 지점을 확정했다. 이번 단계에서는 FE 코드를 수정하지 않았다. +- `map/page.tsx`에는 반경 원 상단 라벨 피처와 빈 지도 클릭 시 현재 지반침하/종속 관로 강조 정리 로직을 추가할 예정이다. +- `SinkholePopupPage.tsx` 목록은 사고번호를 첫 데이터 열로 조정하고 화면 NO 열은 표시하지 않을 예정이다. +- 공통 `Gis.js`, `GisTree.js`에는 기본값이 현행 숨김인 선택 prop을 추가하고 `sn` 지반침하 루트에서만 기존 파란 심볼을 노출할 예정이다. +- 기존 `acdntNo`, `radiusMeters` 계약으로 구현 가능하므로 BE·DB 변경은 없다. +- 실제 코드, 검사 및 브라우저 결과는 다음 구현 단계에서 같은 통합 기록의 후속 절로 추가한다. + +### 4.54 2026-07-14 지도 반경 라벨·트리 심볼·빈 클릭 해제·목록 사고번호 구현 + +- `map/page.tsx`의 지반침하 선택 source에 반경 원 상단 Point 라벨을 추가했다. 문구는 `반경 {radiusMeters}m`이며 원·중심점과 함께 정리된다. +- 지반침하 루트 `iconNm`은 기존 값 우선, 값이 없으면 `SN_SINKHOLE_SPOT_BLUE.png`를 사용하도록 변경했다. +- 공통 `GisTree`의 `sinkholeLegendIconVisible` 기본값을 false로 두고 `sn` 지도에서만 true를 전달해 타 앱 노출을 격리했다. +- 공통 선택 모듈은 선택 결과가 없을 때 빈 배열을 전달하고, `GisSelect`가 선택적 `onMapBlankClick` 콜백을 호출하도록 확장했다. +- `sn` 지도는 GIS 편집 중 빈 클릭을 무시하고 현재 지반침하를 기존 닫기 흐름으로 정리한다. 비활성 지반침하 탭은 활성 탭을 바꾸지 않고 제거하며 종속 관로 부모 링크를 비운다. +- `SinkholePopupPage.tsx` 그리드 첫 데이터 열에 `사고번호(acdntNo)`를 배치하고 화면 `NO` 열은 제거했다. 내부 `rowNo`와 다운로드 순번은 유지했다. +- 반경 내 관로 GeoServer 조회의 기존 storage nullable 타입 경고는 저장소 이름 확인 후 요청하도록 방어 처리했다. +- BE API·DTO·Repository·DB 변경은 없다. +- 브라우저·API·빌드 검증은 사용자 요청에 따라 수행하지 않았으며 사용자 검증 대기 상태다. + +### 4.55 2026-07-14 목록 NO 열 제거 후속 반영 + +- 사용자 후속 결정에 따라 `HEADLIST`에서 `rowNo/NO` 열을 제거했다. +- 사고번호를 첫 화면 열로 유지하고 열 너비를 17로 조정했다. +- `SinkholeTableRow.rowNo`, `toTableRows`, `buildDownloadRows`는 다운로드 순번 호환을 위해 유지했다. +- BE API·DTO·Repository·DB 변경은 없다. +- 브라우저·API·빌드 검증은 사용자 요청에 따라 수행하지 않았으며 사용자 수동 검증 대기 상태다. + +### 4.56 2026-07-14 사용자 통합 검증 완료 + +- 사용자가 통합 화면에서 이번 유지보수 요구사항 전체를 직접 검증했고 모두 정상 동작함을 확인했다. +- 반경 라벨은 실제 반경 값과 일치하고, 지반침하 레이어 트리 심볼은 의도한 위치에 정상 표시됐다. +- 지도 지반침하 선택 후 빈 지도 영역 클릭 시 선택 상세, 반경 원·라벨, 종속 강조가 정상 해제되고 다른 탭과 기본 레이어는 유지됐다. +- 지반침하발생현황 목록은 사고번호가 첫 열로 표시되고 화면 `NO` 열은 제거된 상태로 정상 동작했다. +- BE API·DTO·Repository·DB 계약 변경 없이 최종 상태를 `완료`로 전환했다. + +### 4.57 2026-07-15 일반 관로 다중·지반침하 연계 관로 단일 탭 분리 + +- 원인은 `buildRightPanelTab`이 모든 하수관로에 전역 singleton ID를 부여해 일반 지도 클릭도 기존 관로 탭을 덮어쓰던 것이었다. +- 일반 지도 선택은 `onFeatureSelect`에서 부모 참조를 비우는 기존 흐름을 유지하고, 관로 식별값 기반 탭 ID를 사용하도록 변경했다. +- 지반침하 상세의 반경 내 관로 선택은 `parentTabId`가 있을 때만 `sinkhole-singleton` ID를 사용해 관련 관로 탭 하나를 교체한다. +- 부모가 있는 지반침하 연계 관로는 비활성 상태에서도 `←`를 항상 표시해 일반 관로와 구분되도록 후속 보완했다. +- 탭 최대 5개 초과 시 가장 먼저 열린 탭을 제거하는 FIFO를 유지하고, 제거 대상이 부모 지반침하이면 그 부모를 참조하는 연계 관로 탭도 함께 제거하도록 공통 그룹 제거 함수를 적용했다. 연계 관로 단독 제거는 부모에 영향을 주지 않는다. +- BE API·DTO·Repository·DB 변경은 없다. diff --git a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md new file mode 100644 index 000000000..69784f54b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md @@ -0,0 +1,37 @@ +# SFR-011-002 관로분석 FE 통합구현기록 + +## 1. 문서 목적 +- 관로분석 팝업 유지보수 과정에서 프론트에 반영한 오류 처리 변경을 기록한다. + +## 2. 유지보수 배경 +- `경과년도별 조회`는 정상 동작하지만 `관재질별 조회`, `경과년도 & 관재질별 조회`는 집계 조회 실패 시점에 프론트가 `Failed to fetch data`만 표시해 실제 원인 파악이 어려웠다. + +## 3. 수정 파일 +- `FE_APP_ROOT/app/components/lib/api/index.ts` +- `FE_APP_ROOT/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx` + +## 4. 구현 내용 +- `getData` + - 비정상 응답 본문을 읽어 JSON 또는 text로 보존한다. + - 응답 body의 `message` 또는 중첩 error message가 있으면 그 값을 `Error.message`로 사용한다. + - fallback에는 HTTP status와 statusText를 포함한다. +- `PipeAnalysisPopupPage` + - 집계 조회/다운로드 실패 시 실제 `Error.message`를 우선 alert에 노출한다. + - 엑셀 다운로드 data 캐스팅을 `unknown as JSON[]`로 조정했다. + - 집계 헤더의 `총 N건` 문구를 제거했다. + - 집계 결과는 10개 단위 클라이언트 페이징으로 잘라 렌더링한다. + - `경과년도&재질`은 rowSpan 구조를 유지하기 위해 처리구역 10묶음 기준으로 페이지를 나눈다. + - 모든 분석구분 헤더에 현재 집계 건수를 `N건` 형식으로 노출한다. +- `PipeAnalysisDetailPopupPage` + - 상세 조회/다운로드 실패 시 실제 `Error.message`를 우선 alert에 노출한다. + +## 5. 영향 범위 +- 관로분석 팝업 집계 조회 +- 관로분석 상세 팝업 조회 +- 동일 `getData` 유틸을 사용하는 다른 화면의 오류 메시지 표현 +- 재질별/경과년도&재질별 요약 렌더링 성능 + +## 6. 리스크 및 메모 +- `getData` 공통 유틸 수정이라 다른 화면에서도 이제 서버 메시지가 더 직접적으로 노출된다. +- 서버 메시지 문구가 사용자 친화적이지 않은 API는 추후 별도 화면 단에서 다듬을 수 있다. diff --git a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md new file mode 100644 index 000000000..a485657bb --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md @@ -0,0 +1,176 @@ +# SFR-011-007 통수위험분석 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-011-007 통수위험분석`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 설계 초안부터 FE-only 프로토타입 구현까지의 누적 이력을 기록하며, 이후 실제 BE 연동과 통합 검증 결과를 계속 추가한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md` +- `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md` + +## 3. 공통 계약 + +- 작성자 승인 전에는 코드 수정 없이 문서 설계까지만 진행한다. +- FE 1차 구현은 실제 백엔드 연결 없이도 검토 가능한 범위를 우선한다. +- 결과 목록의 관로 정보는 1~3건 반복형 렌더링으로 구현하고, 고정 3세트 컬럼 가정은 두지 않는다. +- 설정 편집은 중점관리지역 1건 기준 staged 유출관로 1~3건을 FE 로컬 상태에 유지한다. +- 저장 실패 시 staged 목록과 지도 선택 상태는 유지한다. + +## 4. 구현 라운드 요약 + +### 4.1 2026-05-18 설계/초안 라운드 + +- 수행 내용 + - 작업지시 DOCX, HTML, PNG, 시나리오를 기준으로 기능 범위를 재정리했다. + - `SFR-012-003` 문서 구조를 기준으로 공통 설계서, 공통 공유기록, FE 작업카드, FE 구현기록, QA 계획/결과 문서 세트를 정의했다. + - 결과 조회, 지도보기, 설정 현황, 설정 편집/저장 흐름을 FE 단독 범위와 BE 필요 범위로 분리했다. + - 예상 수정 파일과 승인 필요 항목을 정리했다. +- 아직 하지 않은 내용 + - FE 코드 수정 + - popup 등록 + - mock 데이터 연결 + - 지도 overlay 구현 + - staged 설정 상태 구현 + +### 4.2 2026-05-18 FE-only 1차 구현 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` + - `FE_APP_ROOT/src/features/windowPopup/model/types.ts` + - `FE_APP_ROOT/src/features/windowPopup/registry.tsx` + - `FE_APP_ROOT/src/features/windowPopup/ui/WaterRiskPopupContent.tsx` +- 구현 내용 + - `통수위험` popup registry 연결 + - 결과 조회/설정 현황 2탭 popup UI + - 관로 1~3건 동적 컬럼 렌더링 + - 결과 행 선택 시 main map 강조와 sample rain grid overlay 동기화 + - 설정 편집 모드 시작/종료, 지도 하수관로 클릭 기반 staged 목록 추가 + - staged 목록 순번 조정/삭제/초기화 + - 결과 다운로드 preview, 설정 다운로드 preview, 설정 저장 payload preview +- 아직 mock 기준인 항목 + - 실제 통수위험 결과 API + - 실제 중점관리지역 geometry/예측강우 grid 연계 + - 실제 설정 저장/재조회 + - 실제 파일 다운로드 + +### 4.3 2026-05-19 FE 프로토타입 마감 정리 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts` +- 구현 내용 + - 결과 조회/설정 현황 더미데이터를 확대해 페이지네이션이 실제로 보이도록 정리했다. + - 결과 조회와 설정 현황 toolbar 버튼 정렬, 본문 배경, 헤더 컬러를 기존 지도형 기능과 유사한 톤으로 맞췄다. + - 지도 상세정보 카드를 compact 표 구조로 재구성하고, 유출유량이 통수량을 초과하는 경우 강조 표시가 가능하게 정리했다. + - 지도 상세정보 카드 위치는 여러 차례 사용자 피드백을 반영해 프로토타입 기준 위치 조정이 가능한 상태까지 정리했다. +- 아직 mock 또는 후속 검증이 필요한 항목 + - 상세정보 카드의 최종 고정/앵커 규칙 확정 + - 실제 API 응답 구조에 맞춘 결과/상세/설정 데이터 치환 + - 실제 다운로드 및 저장 성공/실패 후처리 + +### 4.4 2026-05-19 FE 추가 보정 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 구현 내용 + - 상세정보 카드 위치 규칙은 사용자 피드백 기준으로 반복 보정되었고, 현재는 직접 추가 조정이 가능한 상태로 유지한다. + - 상세정보 카드 표 구조, 헤더/본문 spacing, 유출유량 초과 강조, 페이지네이션 노출 상태를 사용자 피드백에 맞춰 세부 조정했다. +- 확인 메모 + - UI 세부 보정 과정에서 타입 오류 1건(`buildWaterRiskInfoAnchor`)이 발생했으나 바로 수정했다. + +### 4.5 2026-05-19 상세정보 카드 위치 규칙 보정 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 구현 내용 + - 상세정보 카드는 선택된 중점관리지역의 오른쪽에 붙어 보이는 프로토타입 기준으로 재조정했다. + - 카드 anchor 좌표는 선택 대상 중점관리지역 geometry 기준으로 유지하고, 지도 렌더링 중에는 같은 anchor 기준으로 픽셀 위치를 다시 계산하도록 보정했다. + - 화면 고정형 팝업이 아니라 지도 대상 옆에서 같이 움직이는 정보 카드 방향으로 정리했다. +- 남은 메모 + - 실제 운영에서 요구되는 카드 고정 규칙과 사용성은 브라우저 수동 검증에서 한 번 더 확정할 필요가 있다. + +### 4.6 2026-06-15 통수위험 팝업 디자인 톤 재정렬 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` +- 배경 + - 사용자 피드백 기준으로 현재 `통수위험분석` 팝업은 `관로분석`, `노후관로` 계열과 비교해 별도 그라데이션/카드/버튼 톤이 강해 혼자 튀는 상태로 보였다. + - 이번 라운드에서는 기능 흐름은 유지하고, 시각 언어만 기존 지도형 팝업 계열과 같은 방향으로 다시 맞추는 것을 목표로 했다. +- 구현 내용 + - 상단 헤더를 `bg-primary` + 흰색 제목 구조로 바꿔 `관로분석`, `노후관로` 팝업과 같은 첫인상을 맞췄다. + - 본문 배경을 흰색으로 단순화하고, 조회조건 영역을 `slate-50` 기반 필터 바 형태로 바꿨다. + - 결과/설정 화면 모두에 `card-header` 스타일의 제목 줄을 추가해 기존 팝업의 표 제목 리듬과 맞췄다. + - 버튼 높이, 입력 높이, 테이블 외곽선, 편집 영역 상단 바를 기존 팝업 계열과 가까운 톤으로 조정했다. +- 남은 메모 + - 현재는 `노후관로`/`관로분석` 계열에 시각 톤을 맞춘 1차 정리이며, 실제 사용자 체감 기준에서 간격이나 글자 크기 보정이 더 필요하면 후속 유지보수로 미세조정할 수 있다. + +## 5. 기능별 현재 상태 + +### 5.1 결과 조회 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 날짜/시간/지역/위험상태 필터 + - 결과 목록 + - 관로 1~3건 동적 컬럼 + - 페이지네이션 및 mock 행 렌더링 + +### 5.2 지도보기 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 결과 행 선택 기반 지도 이동 + - 위험/정상 polygon 스타일 + - 예측강우 sample grid overlay + - 상세정보 카드 + +### 5.3 설정 현황 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 결과 popup에서 같은 흐름으로 전환 + - 설정 목록 조회 + - 설정 목록 다운로드 조건 유지 + - 페이지네이션 및 mock 설정 행 렌더링 + +### 5.4 설정 편집/저장 + +- 설계 완료 +- FE-only 프로토타입 구현 완료 +- 핵심 구현 메모 + - 지도 유출관로 선택 + - staged 목록 1~3건 + - 순번 조정 + - 저장 payload preview + +## 6. 예상 구현 순서 + +1. `통수위험` 아이콘과 popup 진입점 연결 +2. 결과 조회/설정 현황 화면 구조 구현 +3. mock 결과 데이터와 동적 관로 컬럼 렌더링 +4. 결과 행 선택 기반 지도 강조와 sample grid overlay +5. staged 유출관로 편집 상태와 저장 payload preview +6. BE 계약 확정 후 실제 API 연결 + +## 7. 남은 리스크 + +- popup 1개 탭 구조와 popup 2개 분리 구조 중 하나를 먼저 확정해야 구현 방향이 흔들리지 않는다. +- 격자 overlay는 mock 단계와 실제 데이터 단계의 렌더 방식 차이가 클 수 있다. +- 설정 편집 모드에서 기존 지도 클릭 기능과 충돌 가능성이 있다. +- 상세정보 카드 위치 규칙은 FE 프로토타입 단계에서 반복 조정된 항목이라 운영 규칙 확정 전 재검토가 필요하다. +- 지도 drag/zoom 중 상세정보 카드의 체감 동작은 실제 사용자 손 기준으로 재확인이 필요하다. + +## 8. 다음 라운드 착수 조건 + +- 실제 API 응답 구조 확정 +- BE 저장/다운로드 계약 확정 +- 인증 가능한 브라우저 환경에서 수동 검증 가능 상태 확보 diff --git a/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md new file mode 100644 index 000000000..db4e62129 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합구현기록.md @@ -0,0 +1,73 @@ +# SFR-011-009 침수발생이력 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-011-009 침수발생이력`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 현재 문서는 docs-first 재정렬 이후의 1차 FE draft 구현 상태를 기록한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md` +- `COMMON_DOCS_ROOT/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합공유기록.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md` + +## 3. 구현 라운드 요약 + +### 3.1 2026-06-15 docs-first 재정렬 + +- 수행 내용 + - 작업지시 DOCX, 시나리오 DOCX, 설계 PNG, HTML 시안을 먼저 읽고 기능 범위를 재정리했다. + - 공통 설계서, FE 작업카드, FE 통합검증계획을 먼저 작성했다. + - 공통 작업현황판/파일점유현황에 `SFR-011-009-DE-FE-PLAN` 진행 상태를 등록했다. + +### 3.2 2026-06-15 FE draft 구현 + +- 반영 파일 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` + - `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` + - `FE_APP_ROOT/src/features/windowPopup/registry.tsx` + - `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx` + - `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` +- 구현 내용 + - 분석도구의 `침수발생이력` 항목은 제거하고, 레이어 트리 `침수흔적도` 목록 진입으로 popup을 열도록 바꿨다. + - `침수발생이력` popup placeholder를 실제 popup 페이지로 교체했다. + - 시도/시군구/침수발생년도 선택/침수재해명 조회조건과 mock 목록 조회/다운로드를 연결했다. + - 그리드는 현재 파란 톤을 유지하되, 사용안함 항목을 제외한 상세 필드를 모두 노출하도록 바꾸고 가로 스크롤을 허용했다. `강우 분석정보` 묶음은 유지하면서 뒤쪽 컬럼을 재배치했다. + - 행 클릭 시 popup을 유지한 채 `postMessage`로 메인 지도에 mock preview를 보내고, 더블클릭 시 popup을 닫도록 구성했다. + - 메인 지도는 mock polygon layer와 침수심 표현 layer를 임시 `VectorLayer`로 그리도록 구성했고, 표시 여부는 `침수흔적도` 체크 상태를 따르도록 보정했다. + - 메인 지도 우측 상세패널은 제거하고, 지도 위 큰 침수흔적 영역 클릭 시 작은 플로팅 팝업이 열리도록 바꿨다. + - 상세 팝업 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 구조에 맞춰 재배치했고, 탭별 표 항목도 캡처 기준에 가깝게 맞췄다. + +## 4. 기능별 현재 상태 + +### 4.1 popup 조회 + +- FE-only draft 구현 완료 +- 레이어 트리 `침수흔적도` 목록 진입 기준 +- 시도/시군구/년도/재해명 조건과 목록 필터, 페이지네이션, 다운로드를 포함한다. + +### 4.2 지도 이동 및 강조 + +- FE-only draft 구현 완료 +- 더블클릭 시 popup close 후 메인 지도 fit 이동과 mock polygon/침수심 표현 overlay를 수행한다. +- `침수흔적도` 체크 상태에 따라 영역과 침수심 overlay를 함께 표시/숨김한다. + +### 4.3 지도 상세 팝업 + +- FE-only draft 구현 완료 +- 선택 이력 기준 3탭 플로팅 팝업을 제공한다. + +## 5. 현재 검증 메모 + +- 로컬 `localhost:3002` 응답은 확인했다. +- `next dev`는 샌드박스 내에서 `spawn EPERM`이 발생해 권한 상승 후 로컬 서버를 띄웠다. +- 사용자가 직접 로컬 화면을 수동 검증할 예정이므로, popup 실제 진입/더블클릭/상세패널 체감 확인은 후속 결과를 받아 갱신한다. + +## 6. 남은 리스크 + +- popup 라우트의 초기 컴파일 시간이 길어 자동 요청에서 timeout이 발생할 수 있다. +- `침수흔적도` 기준데이터 이름/구조와 FE 판별식이 다를 수 있다. +- 실제 API 필드가 확정되면 상세패널 항목명/정렬이 일부 바뀔 수 있다. diff --git a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md new file mode 100644 index 000000000..d6f51e189 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md @@ -0,0 +1,98 @@ +# SFR-011 노후관로관리 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-011 노후관로관리`의 FE 구현 이력과 유지보수 보정 사항을 기능당 1개 문서로 누적 관리한다. +- 이번 라운드는 신규 기능 추가가 아니라, 기존 `노후관로` 목록 팝업의 위치와 폭을 유지보수하는 목적이다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-011-노후관로관리/001-DE-P2-노후관로관리-설계초안.md` +- 사용자 유지보수 요청 + - 하수관망도 `노후관로` 목록보기 팝업 가로 사이즈 축소 + - 지도와 같이 볼 수 있도록 팝업 위치 좌측 상단 쪽으로 이동 + - 폭 축소 후 컴포넌트가 자연스럽게 재배치되도록 보정 + +## 3. 현재 라운드 구현 요약 + +### 3.1 2026-06-04 팝업 위치/폭 유지보수 + +- `agedPipePopup` 최종 크기를 `850 x 600`으로 조정했다. +- 팝업 공통 열기 유틸에 위치 미세조정 옵션을 추가해 `노후관로` 팝업이 `placement: "browser-left"`, `alignTop: true`, `offsetTop: 200` 기준으로 열리도록 정리했다. +- 상단 조회 조건 영역은 `경과년도 / 시도 / 시군구 / 조회 / 다운로드`가 한 줄로 보이도록 맞췄다. +- 현재 팝업 높이에서 표 아래 공백이 과도하지 않도록 높이 기준을 재조정했다. + +### 3.2 2026-06-04 후속 미세조정 + +- 사용 피드백 기준으로 팝업 위치는 현재 좌측 기준 + 세로 오프셋 `200px` 배치를 유지했다. +- 조회/다운로드 버튼을 `시군구`와 같은 수평선에서 우측 정렬되도록 다시 조정했다. +- 필터 영역 하단 여백을 줄여 버튼 아래 빈 공간이 남지 않게 보정했다. +- `UiTable`에 전달하는 외곽 마진을 제거하고, 중복 총 건수 문구를 숨겨 팝업 내부 가로/세로 스크롤 발생 가능성을 낮췄다. +- 표 영역은 현재 팝업 폭과 높이 안에서 보이도록 유지하는 방향으로 정리했다. + +### 3.3 2026-06-11 시설물 트리 하단 설정영역 분리 + +- 기존 `노후관로 설정` 패널은 시설물 트리 안에서 `노후관로` 노드 바로 아래에 인라인으로 열렸지만, 지반침하정보와 동일하게 “레이어 사용 시 하단 설정영역에 추가되는 방식”으로 맞춰 달라는 요청이 들어왔다. +- 이번 유지보수에서는 `노후관로 설정` 패널 컴포넌트 자체는 유지하고, `map/page.tsx`에서 `isAgedPipeChecked`일 때만 `Gis` 하단 `layerSettingView` 영역에 렌더되도록 위치만 조정했다. +- 체크 해제 시에는 설정 패널도 같이 사라지고, 목록보기/필터 적용/지도 표시 흐름은 기존과 동일하게 유지한다. +- 후속 보정으로 하단 영역은 `선택 레이어 설정` 묶음으로 감싸고, 선택된 설정이 없으면 섹션 자체가 사라지도록 해 “선택된 게 있을 때만 뜨는 느낌”을 명확히 했다. +- 같은 영역 안에서 `지반침하정보`와 `노후관로 설정` 패널이 서로 다른 폭으로 보이지 않도록 두 패널 모두 하단 영역 기준으로 동일 폭을 쓰게 맞췄다. +- 추가 보정으로 별도 상단 구분 타이틀은 제거하고, 시설물 레이어 목록을 얇은 사각형 프레임으로 감싸 위 레이어 영역과 아래 설정영역의 구획만 남기도록 정리했다. +- 스크롤바가 생길 때 레이어명이 2줄로 꺾이는 현상을 줄이기 위해, 공용 트리 스크롤 영역에 `scrollbar-gutter: stable`을 적용하고 레이어명은 한 줄 `truncate` 기준으로 보정했다. +- 공용 트리 접기 버튼이 스크롤바를 가려 보이는 배치도 함께 조정해, 버튼이 패널 외곽 핸들처럼 보이도록 옮겼다. +- 후속 조정으로 버튼이 잘려 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다. +- 추가 수정으로 토글 버튼이 패널의 `overflow-hidden` 범위에 포함돼 실제로는 잘려 보이지 않던 구조를 분리해, 패널 바깥 형제 위치에서 항상 보이도록 보정했다. +- 최종 보정으로 토글 시각 스타일은 분석도구 패널과 같은 `36x20` 핸들 디자인을 재사용하고, 위치는 패널 바깥 중앙으로 유지하도록 정리했다. +- 추가 구조 보정으로 시설물 레이어 박스는 항상 윤곽이 유지되게 하고, 스크롤은 그 박스 내부에서만 동작하도록 바꿨다. 토글 핸들 위치도 박스 기준 중앙으로 다시 정리했다. +- 후속 위치 보정으로 토글 핸들이 박스 오른쪽 경계에 자연스럽게 걸치도록 다시 맞춰, 지도 영역 쪽으로 과하게 밀려나 보이던 배치를 줄였다. +- 후속 가시성 보정으로 토글 핸들 크기, 파란 계열 대비, 그림자를 함께 올려 지도 배경 위에서도 더 눈에 띄도록 정리했다. +- 추가 구조 보정으로 패널은 `바깥 전체 스크롤`, 시설물 레이어 박스는 `고정 높이 + 내부 스크롤`로 분리해 생활안전지도와 비슷한 사용 흐름으로 정리했다. +- `침수심 범례`는 선택 레이어 설정 안에서 별도 토글을 두지 않고, 관련 침수 레이어가 선택된 경우 실제 범례 항목을 그대로 확인하는 정보 카드로 반영했다. +- 스크롤바는 폭만 얇게 유지하고 색상은 기본값으로 되돌려, 생활안전지도처럼 덜 눈에 띄는 톤으로 정리했다. +- 후속 정리로 선택 레이어 설정에는 침수심 범례 본문만 남기고, 지도 우측 하단에 따로 뜨던 기존 범례 박스는 제거했다. +- 추가 구조 보정으로 토글 핸들을 패널 본체와 분리된 고정 핸들로 바꿔, 닫았을 때도 화면 왼쪽 가장자리에 다시 열기 버튼이 남도록 정리했다. + +## 4. 실제 수정 파일 + +- `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx` +- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` +- `FE_APP_ROOT/src/features/windowPopup/model/types.ts` +- `FE_APP_ROOT/src/features/windowPopup/lib/openWindowPopup.ts` + +## 5. 구현 상세 + +### 5.1 팝업 정의 + +- `agedPipePopup` + - `width: 850` + - `height: 600` + - `placement: "browser-left"` + - `alignTop: true` + - `offsetLeft: 0` + - `offsetTop: 200` + +### 5.2 공통 팝업 유틸 + +- 기존 `center`, `browser-left` 배치 외에, 개별 팝업이 좌표를 미세 조정할 수 있도록 아래 속성을 허용했다. + - `alignTop` + - `offsetLeft` + - `offsetTop` +- 이번 라운드에서는 `agedPipePopup`만 이 옵션을 사용한다. + +### 5.3 팝업 본문 레이아웃 + +- 최상위 컨테이너에 `min-w-0`, `min-h-0` 기준을 추가했다. +- 조회조건 영역을 `시군구`와 버튼이 같은 줄에서 정렬되도록 재배치했다. +- 표는 현재 팝업 높이 안에서 하단 공백이 과도하지 않게 보이도록 구성했다. + +## 6. 영향 범위 + +- `노후관로` 목록 팝업 초기 오픈 위치 +- `노후관로` 목록 팝업 초기 오픈 폭 +- 현재 팝업 높이에서의 조건영역/표 레이아웃 +- 다른 팝업은 기본 동작을 유지하며, 이번 위치 미세조정 옵션은 사용하지 않는다. + +## 7. 남은 리스크 + +- 사용자 스크린샷 기준의 정확한 위치 체감은 실제 브라우저 수동 확인이 필요하다. +- 최소화 후 다시 열 때 사용하는 메인 화면의 미니 카드 위치는 이번 라운드 범위에 포함하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md new file mode 100644 index 000000000..8f9d81d59 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md @@ -0,0 +1,116 @@ +# SFR-012-002 하수관망도 업로드 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-012-002 하수관망도 업로드`의 FE 구현 이력, 결정사항, BE 연동 경계, 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 구현 전 단계에서는 설계 기준과 착수 조건을 먼저 기록한다. +- 이후 라운드부터는 새 파일을 추가하지 않고 이 문서 내부 헤더만 추가한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md` + +## 3. 공통 계약 + +- `GIS 관리`는 지도 화면 내부 로컬 편집 모드가 아니라 `window popup` 업무 흐름으로 구현한다. +- 업로드 대상 GIS는 원본과 분리된 업로드본으로 저장된다는 업무 규칙을 FE 문구와 payload 구조에 모두 반영한다. +- 승인 전에는 코드 구현을 시작하지 않는다. +- FE는 승인 후에도 실제 업로드/다운로드 API 연결 전까지는 mock 이력과 payload 준비 상태까지만 구현한다. + +## 4. 라운드 요약 + +### 4.1 2026-05-15 설계 착수 + +- 작업지시의 요구사항 명세서, 시나리오, HTML, PNG를 읽고 `SFR-012-003`과 비교했다. +- `SFR-012-003`이 지도 위 직접 편집 중심이었다면, `SFR-012-002`는 popup 기반 업로드 관리라는 점을 핵심 차이로 확정했다. +- canonical 문서 구조는 그대로 재사용하되, 이번 기능의 구현 후보를 `src/features/windowPopup/**` 중심으로 재설계했다. + +### 4.2 2026-05-15 FE-only popup 구현 + +- `gisManagementPopup` placeholder를 실제 popup content로 교체했다. +- 구현 범위 + - 시설구분 select + - 파일 찾기 input + - 파일 확장자/크기 1차 검증 + - mock 업로드 이력 갱신 + - 다운로드 대상 선택 + - 업로드/다운로드 요청 미리보기 +- 구현 원칙 + - 실제 API 호출 없이도 백엔드 계약과 payload 구조를 검토할 수 있어야 한다. + - 업로드 성공 시 같은 시설구분의 기존 최신 참조 행은 `이력` 상태로 내리고, 신규 행을 `최신 참조`로 올리는 mock 규칙을 둔다. + - 미지원 시설구분은 select에서 비활성 처리해 예외 흐름을 먼저 노출한다. +- 실제 수정 파일 + - `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx` + - `FE_APP_ROOT/src/features/windowPopup/registry.tsx` + - `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` + +### 4.3 2026-05-18 popup 구조/스타일 재정리 + +- `windowPopup` 내부에서 직접 화면을 모두 그리던 방식을 걷어내고, `GisManagementPopupContent`는 얇은 wrapper로 두고 실제 화면은 `GisManagementPopupPage`로 분리했다. +- 분석도구 popup과 비슷한 타이포/버튼/테이블 톤으로 재정리했다. +- 팝업 초기 폭과 높이를 조정하고, 내부 컨텐츠는 창 크기에 맞춰 유동적으로 늘어나도록 변경했다. +- 업로드 현황은 10건 단위 페이징을 적용했고, 기본적으로 5행 수준이 편하게 보이도록 높이를 조정했다. +- `GIS 관리` window popup을 닫으면 분석도구 메뉴 active가 같이 해제되도록 보완했다. +- `찾기`, `업로드` 버튼은 상황판/검색영역 `조회` 버튼 계열 색으로 통일했다. +- `다운로드` 버튼은 운영정보 `파일 다운로드`와 같은 `green` 계열 버튼으로 분리했다. +- 업로드 row는 좌측 입력 그룹과 우측 버튼 그룹을 분리 정렬하고, 버튼 간격을 별도로 조정했다. + +### 4.4 현재 FE 구현 범위 확정 + +- 구현 완료 범위 + - `GIS 관리` popup 진입 + - 하수관로 단일 시설구분 표시/사용 + - 파일 찾기 + - 확장자/크기 1차 검증 + - mock 업로드 이력 누적 + - 업로드 현황 10건 페이징 + - 다운로드 대상 선택 + - popup close 시 분석도구 active 해제 + - 버튼 색/정렬/간격 정리 +- 미완료 범위 + - 실제 업로드 API 호출 + - 실제 다운로드 바이너리 응답 + - 업로드 성공 후 서버 이력 재조회 + - 실제 시설물 레이어 기반 시설구분 동기화 + +### 4.5 2026-05-21 업로드 대상 하수관로 단일화 + +- 운영 제약 기준으로 `GIS 관리` 업로드 대상은 하수관로만 사용한다. +- 이에 따라 `UPLOAD_FACILITY_TYPES` mock 상수에서 하수맨홀, 하수처리시설 항목을 제거했다. +- 업로드 현황 mock 목록도 하수관로 이력만 남기고 정리했다. +- FE 문서와 공통 공유 기록에서도 다른 GIS 테이블은 타회사 소유 범위라 업로드 대상에서 제외한다는 근거를 함께 남긴다. +- 현재 구조는 `/map` 진입 시 전체 레이어 트리를 읽더라도 `GIS 관리` popup 소비 단계에서는 하수관로만 노출하는 방식으로 유지한다. + +## 5. BE 공유 초안 + +### 5.1 FE가 기대하는 메타 정보 + +- 업로드 가능 시설구분 목록 +- 시설구분별 허용 확장자 +- 시설구분별 최대 파일 크기 +- 시설구분별 업로드 가능 여부 + +### 5.2 FE가 기대하는 업로드 응답 정보 + +- `uploadId` +- `facilityTypeCode` +- `facilityTypeName` +- `fileName` +- `registeredAt` +- `registeredBy` +- `activeYn` +- 최신 참조 갱신 여부 + +### 5.3 FE가 기대하는 다운로드 조건 + +- 선택 업로드 ID 기준 다운로드 +- 권한 없음 / 파일 없음 / 다운로드 실패 메시지 구분 + +## 6. 남은 리스크 + +- popup UI를 어느 수준까지 기존 공용 폼 패턴으로 맞출지 구현 직전에 다시 확인이 필요하다. +- 업로드 후 지도 레이어를 즉시 새 업로드본 기준으로 refresh할지 여부는 서버 계약 없이는 확정하기 어렵다. +- 실제 upload history 스키마가 설계 초안과 달라질 수 있다. +- 현재 업로드/다운로드는 요청 미리보기와 mock 목록 갱신만 수행하므로, 실제 파일 바이너리 흐름은 백엔드 구현 후 재검증이 필요하다. +- 시설구분 목록은 현재 `UPLOAD_FACILITY_TYPES` mock 상수에 고정돼 있어 실제 좌측 `시설물 레이어`와 다를 수 있다. diff --git a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md new file mode 100644 index 000000000..d764b6ba6 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md @@ -0,0 +1,490 @@ +# SFR-012-003 하수관로편집 통합 구현 기록 + +## 1. 문서 목적 + +- `SFR-012-003 하수관로편집`의 FE 구현 이력, 결정사항, 남은 리스크를 기능당 1개 문서로 누적 관리한다. +- 기존 `001~006` 구현 문서를 이 문서로 흡수한다. +- 이후 라운드부터는 새 파일을 추가하지 않고 이 문서 내부 헤더로만 구분한다. + +## 2. 입력 근거 + +- `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` +- `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면시나리오_v1_1.md` +- `FE_APP_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md` + +## 3. 공통 계약 + +- 편집 결과는 원본 GIS를 직접 수정하지 않고 FE 로컬 세션에 우선 누적한다. +- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE`는 동시에 활성화하지 않는다. +- `ADD` + - geometry family별 직접 캡처 방식으로 구현한다. + - `POINT`는 클릭 1회, `LINE`은 연속 클릭 + 종료, `POLYGON`은 연속 클릭 + 폐합 종료를 따른다. +- `MOVE` + - preview feature drag 기반으로 유지한다. +- `VERTEX_EDIT` + - QGIS vertex tool 기준으로 vertex marker를 노출하고, 양 끝점 포함 vertex 이동이 가능해야 한다. +- `DELETE` + - 원본을 즉시 지우지 않고 임시 삭제 상태로만 유지한다. + +## 3-1. 편집도구 아이콘 기준 + +현재 구현과 문서 표기는 화면 시안 기준 편집도구 아이콘 의미를 따른다. + +| 도구 | 아이콘 | 메모 | +| --- | --- | --- | +| 추가 | | geometry family별 직접 캡처 대상 | +| 이동 | | preview drag 유지 기준 | +| 꼭지점편집 | | 양 끝점 포함 vertex 조정 목표 | +| 삭제 | | 임시 삭제 상태 유지 | +| 저장 | | 원본 분리 저장 계약 | + +## 4. 구현 라운드 요약 + +### 4.1 초기 범위 정리 + +- FE 로컬 세션, 툴바, 저장 payload 미리보기 구조를 준비했다. +- 백엔드 공유용 편집 메타/저장 API 초안을 정리했다. + +### 4.2 레이어/도구 확장 + +- 편집 후보 레이어를 7종으로 확장했다. +- 레이어별 허용 도구 활성 규칙을 추가했다. +- 세션 `undo`, `reset`을 추가했다. + +### 4.3 `MOVE` + +- preview feature 기반 `Translate` interaction을 연결했다. +- 같은 객체 재선택 시 중복 change 누적을 줄이는 focus 흐름을 추가했다. +- 현재 `MOVE`는 유지 기준으로 본다. + +### 4.4 `VERTEX_EDIT` + +- 여러 차례 interaction 보강을 시도했으나, 요구사항과 다른 구현 흔적이 남았다. +- 현재 핵심 재정렬 기준: + - vertex marker가 보일 것 + - 양 끝점 이동 가능 + - `MOVE`와 서로 회귀를 일으키지 않을 것 + +### 4.5 현재 라운드 `ADD` 재구현 시작 + +- 기존 지도 중심 임시 생성 방식을 폐기했다. +- `ADD` 도구 선택 시 geometry type별 `Draw` interaction을 연결하는 방향으로 재구현 중이다. + +### 4.6 2026-05-15 세션 유지 및 상호작용 회귀 대응 + +- 사용자 피드백 기준 이슈 + - `ADD`, `MOVE`, `VERTEX_EDIT` 변경분이 화면에서 바로 사라져 세션 추적이 어렵다. + - `MOVE` 선택 후 드래그가 실제로 움직이지 않는다. + - `VERTEX_EDIT`는 주황색 점이 기대보다 빈약하게 보이고 드래그 편집이 체감되지 않는다. +- 이번 라운드 반영 + - `GIS편집` 모드를 꺼도 변경분이 있으면 세션 패널과 지도 preview를 유지하도록 조정했다. + - preview layer를 `updateWhileInteracting=true`로 바꾸고, interaction start/end 시 `renderSync`와 source 변경 반영을 보강했다. + - 원본 피처 선택 시 layerId 비교가 namespace/feature id 형식 차이로 깨지지 않도록 layerId 정규화를 추가했다. + - interaction effect가 `gisEdit` 전체 객체 변경에 과민하게 반응하지 않도록 의존성을 좁혀 `MOVE`, `VERTEX_EDIT` 재연결 흐름을 안정화했다. + - 세션 패널은 편집 모드 종료 후에도 변경분이 남아 있으면 계속 노출되도록 바꿨다. + - 신규 추가 preview는 레이어별 색상을 우선 사용하도록 보강해 같은 화면에서 여러 시설을 편집해도 사용자가 구분할 수 있게 했다. + - `하수처리시설(tb_il_swage_prcs_fclt)`은 실제 레이어 설정 기준에 맞춰 `Point`로 정정하고 `VERTEX_EDIT` 대상에서 제외했다. + +### 4.7 2026-05-15 편집 세션 패널 축소/확장 UX 보강 + +- 편집 세션이 길어질 때 지도를 과도하게 가리는 문제가 있어 기본 패널은 최근 5건만 표시하도록 줄였다. +- 기본 패널에는 `총 N건` 배지와 `전체 보기` 버튼을 추가했다. +- `전체 보기`는 드래그 가능한 팝업으로 분리해 사용자가 지도 위에서 위치를 옮기며 확인할 수 있게 했다. +- 편집 모드를 종료한 뒤에도 세션이 남아 있으면 compact 패널과 전체 보기 팝업에서 계속 확인할 수 있다. + +### 4.8 2026-05-15 편집 세션 패널 단순화 + +- 사용자 피드백에 따라 `전체 보기` 팝업은 제거했다. +- 대신 기본 패널 높이를 최근 5건 정도만 보이는 크기로 고정하고, 패널 내부 스크롤로 아래 이력을 바로 확인할 수 있게 바꿨다. +- 결과적으로 지도 맥락은 유지하면서도 추가 팝업 조작 없이 세션 전체를 훑을 수 있는 구조로 단순화했다. +- 후속 조정으로 패널 높이를 한 번 더 줄이고, 하단 안내 문구 영역도 제거해 화면 점유를 더 낮췄다. + +### 4.9 2026-05-15 QGIS Vertex Tool 기준 재설계 착수 + +- QGIS 공식 문서 기준 확인 결과, Vertex Tool은 “모든 실꼭지점 표시”, “끝점 이동”, “segment 중간 가상점 추가”, “selection persistent”를 기본으로 제공한다. +- 기존 구현은 OpenLayers `Modify` 기본 overlay 위주여서 marker 표시와 endpoint drag 체감이 QGIS와 크게 달랐다. +- 이번 라운드부터 `VERTEX_EDIT`는 아래 구조로 재구성한다. + - preview geometry와 분리된 vertex handle 전용 레이어 + - 실꼭지점 handle + - segment 중간 가상 handle + - handle drag 후 geometry 재조립 및 세션 반영 + +### 4.10 2026-05-15 `VERTEX_EDIT` QGIS 유사화 재구현 + +- 이번 라운드 결정 + - `ADD`, `MOVE`는 기준선으로 유지하고 `VERTEX_EDIT`만 재구현 대상으로 제한했다. + - OpenLayers `Modify` 기본 overlay로 되돌리지 않고, 기존 custom handle layer 방향을 유지하되 실험 코드를 단순화했다. + - “모든 midpoint 상시 표시” 방식은 버리고 “실꼭지점 상시 + hover segment midpoint 1건” 구조로 바꿨다. +- 코드 반영 + - 선택 preview feature의 실제 vertex만 항상 렌더링하도록 handle 생성 함수를 분리했다. + - `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`에서 segment 후보를 계산하는 midpoint candidate 수집 유틸을 추가했다. + - pointer move 기준으로 가장 가까운 segment 1건만 midpoint handle을 노출하고, vertex 근처에서는 midpoint를 숨기도록 pixel tolerance 로직을 넣었다. + - midpoint drag 시 해당 segment 사이에 새 좌표를 삽입하고, vertex drag 시 기존 좌표만 이동하도록 geometry 재조립 흐름을 유지했다. + - interaction 시작/종료 중 midpoint hover가 섞여 드래그가 흔들리지 않도록 dragging flag와 pointer move cleanup을 분리했다. + - `MOVE` preview layer의 `clickPsblYn="Y"` 기준은 그대로 유지했고, vertex layer는 계속 별도 레이어로 유지했다. +- 사용자 체감 개선 목표 + - 어떤 점이 실꼭지점인지 즉시 보인다. + - midpoint는 필요한 segment 근처에서만 나타나 화면이 덜 산개된다. + - endpoint도 일반 vertex와 같은 방식으로 조작된다. + +### 4.11 2026-05-15 편집 세션창 팝업 전환 및 도구 아이콘 정리 + +- 사용자 추가 요청 기준 반영 + - 편집 세션창의 “현재 구현 ... 검토합니다” 안내 영역을 제거했다. + - 지도 좌하단 고정 세션 패널은 제거하고, 기존 `window popup` 체계의 `GIS 편집 세션` 팝업으로 전환했다. + - 팝업 기본 크기는 세션 목록과 payload 미리보기를 함께 볼 수 있게 `520 x 820`으로 잡았다. + - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼은 분석도구와 같은 `lucide-react` 아이콘을 붙였다. +- 구현 방식 + - 메인 `/map` 화면은 GIS 편집 모드 진입 시 세션 팝업을 열고, postMessage로 세션 snapshot을 동기화한다. + - 팝업에서 `마지막 변경 취소`, `세션 초기화`, `항목 focus`를 누르면 다시 메인 창으로 메시지를 보내 실제 편집 세션을 제어한다. + - 기존 `gisEditPopup` placeholder는 유지하고, 세션 전용 팝업은 `gisEditSessionPopup`으로 별도 등록했다. + +### 4.12 2026-05-15 중복 endpoint handle 통합 + +- 사용자 피드백 기준 이슈 + - 관로 시점/종점 주변에 같은 위치의 주황색 꼭지점이 2개씩 겹쳐 보이는 경우가 있었다. +- 이번 라운드 반영 + - vertex handle 생성 시 raw vertex를 그대로 전부 노출하지 않고, 같은 좌표를 공유하는 vertex들은 화면에서 handle 1개로 묶었다. + - 단순 표시만 합친 것이 아니라, 묶인 handle을 드래그하면 같은 좌표를 공유하던 raw vertex들을 함께 이동시키도록 처리했다. + - 내부 `MultiLineString`/분리 part 구조는 유지하고, 표시와 드래그 동작만 통합했다. + +### 4.13 2026-05-15 외부 제출용 단일 산출물 생성 + +- 사용자 요청으로 오늘 작업 내역, 설계 변경, 검증 계획, 검증 결과를 묶은 제출용 단일 `md` 파일을 별도 생성한다. +- 이는 통합 문서 체계 예외 산출물이며, 기능 운영 문서는 기존 canonical 4문서 체계를 계속 기준으로 유지한다. + +### 4.14 2026-05-21 관로 기본정보 세션 편집 결합 + +- 실제 구현 재점검 결과 + - `useGisEdit`는 geometry change 세션만 관리하고 있었다. + - 관로 상세정보 저장은 기존 `FeatureClickEventPopup` 내부 `POST /api/v1/fcltppl` 즉시 저장 구조였다. + - 편집 모드에서는 피처 클릭이 상세 팝업 대신 session change 누적으로 우선 처리돼, 이동/꼭지점편집 중 기존 상세정보를 보며 수정하는 흐름이 없었다. +- 이번 라운드 반영 + - `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`를 추가했다. + - 기존 상세정보 구현 파일은 직접 수정하지 않는 방향으로 정리하고, GIS 편집 전용 하수관로 팝업을 별도 컴포넌트로 분리했다. + - 신규 추가는 같은 상세정보 형태의 편집 팝업을 빈 값으로 열고, 기존 관로 이동/꼭지점편집은 기존 값이 채워진 상태로 연다. + - 편집 전용 팝업은 `기본정보` editable 테이블과 `입력정보` editable 테이블만 표시하고, `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션은 렌더링하지 않는다. + - 관로 기본정보는 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 읽어 오되, 저장은 즉시 API 호출이 아니라 change draft 갱신으로만 처리한다. + - 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 구조로 다시 조립하도록 바꿨다. +- 남긴 판단 + - 현재 백엔드에는 geometry batch save API가 없어 FE는 payload 조립과 저장 지점 정렬까지 수행한다. + - 실제 DB 반영은 `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조를 함께 처리하는 백엔드 계약이 추가로 필요하다. + +### 4.15 2026-05-21 편집 팝업 자동저장 루프 및 선택 제한 보강 + +- 사용자 피드백 기준 이슈 + - 편집 팝업 입력 시 스크롤이 상단으로 튀고, 일부 입력에서는 `Maximum update depth exceeded` 런타임 에러가 발생했다. + - `MOVE`, `VERTEX_EDIT` 중 하수관로 외 객체를 클릭하면 지도 기본 선택 흐름이 타면서 줌 이동이 발생했다. + - `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택해도 편집 상세정보창이 항상 즉시 뜨지 않았다. +- 원인 정리 + - 편집 팝업 draft를 `useEffect`에서 자동 저장하도록 구성하면서 `팝업 입력 -> parent change 갱신 -> 팝업 재초기화 -> 재저장` 루프가 생겼다. + - `detailView` wrapper가 부모 렌더마다 재생성되어 스크롤/포커스가 흔들릴 여지가 있었다. + - `MOVE`, `VERTEX_EDIT`의 상세정보창 오픈을 preview 준비 완료 시점에 의존시켜 선택 직후 오픈이 누락되는 경우가 있었다. +- 이번 라운드 반영 + - 자동 세션 저장은 렌더 기반 effect에서 제거하고, 실제 `input/select` 변경 이벤트 시점에만 draft를 세션에 반영하도록 바꿨다. + - `detailView` wrapper를 안정화해 편집 팝업이 입력 중 불필요하게 remount되지 않도록 조정했다. + - `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택하면 `setTimeout(0)` 기준으로 상세정보창을 즉시 열도록 단순화했다. + - 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해, 하수관로 편집 중에는 비하수관로 피처를 아예 선택 단계에서 차단하도록 확장했다. + - 차단된 경우에는 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다. + +### 4.16 2026-06-08 스냅핑 토글 및 Delete 키 꼭지점 삭제 + +- 사용자 요구사항 기준 + - `이동`과 `꼭지점편집` 사이에 QGIS 유사 `스냅핑` on/off 토글을 두고 실제 프론트 interaction에서 사용 가능해야 한다. + - `꼭지점편집` 중 주황색 실꼭지점을 클릭한 뒤 키보드 `Delete`를 누르면 해당 꼭지점을 삭제하고, 남은 꼭지점끼리 다시 연결돼야 한다. +- 이번 라운드 반영 + - `useGisEdit`에 `isSnapEnabled` 상태와 `toggleSnapEnabled`를 추가하고, 툴바와 세션 팝업 스냅샷에 함께 노출했다. + - `GisEditToolbar`에 `꼭지점편집` 오른쪽 `스냅핑` 버튼을 추가했다. + - 처음 구현은 `MOVE`, `VERTEX_EDIT`에만 스냅을 붙여 `ADD`에서는 스냅핑이 동작하지 않는 문제가 있었다. + - 후속 보정으로 `ADD`, `MOVE`, `VERTEX_EDIT` 모두 현재 보이는 `tb_ms_swage_ppl` 원본 feature와 다른 preview feature를 수집해 OpenLayers `Snap` interaction을 함께 부착하도록 수정했다. + - `VERTEX_EDIT` handle feature에 선택 키를 부여해, 사용자가 주황색 실꼭지점을 클릭하면 선택 상태를 유지하도록 보강했다. + - 전역 `keydown`에서 `Delete`를 감지하되 `input`, `textarea`, `select`, contentEditable 포커스 중에는 무시하도록 방어했다. + - `Delete` 대상은 주황색 실꼭지점만 허용하고, 흰색 midpoint는 “새 점 삽입 전용”으로 유지했다. + - `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon` 기준 삭제 유틸을 추가했지만, 현재 운영 범위상 핵심 대상은 하수관로 선형이다. + - 선형 최소 꼭지점 규칙을 넣어 하수관로가 2점 미만이 되는 삭제는 차단했다. +- 남긴 판단 + - 현재 스냅 대상은 지도 source에 로드된 하수관로 원본/preview만 사용하므로 화면 밖 또는 비로딩 객체까지 보장하지는 않는다. + - 겹쳐 있는 handle은 기존 구현처럼 하나의 묶음으로 표현되므로, 같은 좌표를 공유하는 raw vertex는 Delete 시 함께 제거될 수 있다. + +### 4.17 2026-06-08 QGIS 기준 1차 스냅 재구현 + +- 사용자 재피드백 기준 이슈 + - 스냅핑 `ON` 상태에서도 `ADD`가 기존 오수관에 붙지 않았다. + - `MOVE`, `VERTEX_EDIT`도 원본 하수관로를 기준으로 붙는 체감이 약했다. +- 원인 재정리 + - 기존 구현은 “지도에 이미 feature로 올라와 있는 객체”를 즉석에서 수집해 snap 대상으로 쓰는 구조였다. + - 하수관로 원본이 WMS로만 보이는 경우에는 브라우저 메모리에 snap 기준 feature가 충분히 없을 수 있어, 사용자는 관로가 보여도 실제로는 스냅 기준 geometry가 없는 상태가 생긴다. +- 이번 1차 재구현 반영 + - 범위를 `ADD`, `VERTEX_EDIT` 우선으로 줄여 시작했지만, 후속 보정으로 `MOVE`도 같은 원본 하수관로 snap source를 사용하도록 확장했다. + - 현재 화면 extent를 기준으로 `tb_ms_swage_ppl` WFS를 직접 다시 조회해 전용 `pipe snap source`를 만든다. + - `ADD`는 `Draw + Snap(source)` 구조로, `VERTEX_EDIT`는 custom vertex handle + `Snap(source)` 구조로 다시 연결했다. + - `MOVE`도 preview 수집 기반 fallback보다 원본 하수관로 WFS snap source를 우선 사용하도록 보강했다. + - `VERTEX_EDIT`에서 기존 관로를 편집하는 경우 자기 자신 원본 fid는 snap source에서 제외해 자기 geometry에 다시 달라붙는 문제를 줄였다. + - 지도 `moveend` 시 snap source를 다시 조회해 현재 보이는 하수관로 기준을 유지하도록 보강했다. +- 현재 한계 + - 이번 1차는 `MOVE`를 아직 QGIS 기준으로 다시 고정하지 않았다. + - 수동 브라우저 검증 전이라 실제 체감과 시각 피드백은 아직 최종 확정 상태가 아니다. + +### 4.18 2026-06-08 MOVE 기준점 재정렬 및 입력정보 조회 방어 + +- 사용자 재피드백 기준 이슈 + - `ADD`, `VERTEX_EDIT`는 비교적 잘 붙는데 `MOVE`는 붙는 위치와 안 붙는 위치가 섞여 보였다. + - 이동 중 관로 상세 팝업이 열릴 때 `fetchPipeInputInfo`에서 `500`이 간헐적으로 발생했다. +- 원인 정리 + - 기존 `MOVE`는 드래그 후 커서 좌표나 임의의 가장 가까운 꼭지점 기준으로 보정해, 사용자가 실제로 잡아 끈 관로 끝점과 snap 기준점이 달라질 수 있었다. + - 관로 입력정보 조회는 `fcltMngNo`, `mngInstCd`를 path에 그대로 넣고 있어 특수문자/임시값 케이스에 취약했다. +- 이번 라운드 반영 + - `MOVE`는 관로 선택 순간 클릭 위치에서 가장 가까운 관로 꼭지점 meta를 `move grip`으로 저장한다. + - 드래그 시작/중/종료 모두 원본 geometry snapshot을 기준으로 다시 계산하고, 선택된 `move grip`이 원본 하수관로 snap source에 붙도록 전체 geometry를 재정렬한다. + - 즉 `MOVE`도 `ADD`, `VERTEX_EDIT`처럼 “명확한 한 점을 기준으로 붙는다”는 QGIS 관점에 더 가깝게 재해석했다. + - `GisEditPipeSessionPopup`의 입력정보 조회는 path segment를 `encodeURIComponent(...)` 처리하고, `NEW-*` 임시값은 조회하지 않도록 방어했다. +- 남긴 판단 + - `MOVE` 체감이 여전히 어색하면 다음 단계는 grip point를 지도 위에 직접 시각화해 사용자가 “지금 어떤 점을 잡고 이동 중인지”를 보이게 해야 한다. + +### 4.19 2026-06-26 MOVE 도구 on/off 유지보수 분석 + +- 사용자 요구사항 + - `GIS편집`에서 `이동`을 한 번 누르면 여러 관로를 연속으로 이동할 수 있어야 한다. + - `이동` 버튼을 다시 누르면 이동 모드가 꺼지는 on/off 방식이어야 한다. +- 현재 코드 확인 + - `GisEditToolbar`는 `activeTool === "MOVE"`일 때 `이동` 버튼을 active 색상으로 표시한다. + - `handleSelectGisEditTool()`은 같은 도구를 다시 누르면 `gisEdit.deactivateTool()`을 호출하므로 버튼 재클릭 off 구조는 이미 있다. + - 문제는 `MOVE` `Translate`의 `translateend`가 끝날 때 `gisEdit.updateChangeGeometry(...)`를 호출하고, 해당 훅 함수가 기본적으로 `setActiveTool(null)`과 `setPendingInteraction(null)`을 실행하는 데 있다. + - 따라서 한 번 이동이 끝나면 사용자가 버튼을 다시 누른 것이 아니어도 `activeTool`이 `null`로 바뀌어 이동 도구가 꺼진다. +- 다음 구현 방향 + - `MOVE` 완료 후에는 `activeTool`을 `"MOVE"`로 유지한다. + - 이동이 끝난 같은 preview feature에 interaction이 계속 묶이지 않도록 `pendingInteraction`은 비운다. + - 다음 관로 또는 preview feature를 클릭하면 기존 `registerFeatureSelection()` 흐름이 새 `pendingInteraction`을 만들고 이동 interaction을 다시 붙이게 한다. + - 기존 `VERTEX_EDIT`의 `keepVertexEditActive` 동작과 충돌하지 않도록 `MOVE` 전용 옵션 또는 범용 `keepActiveTool` 옵션으로 좁혀 구현한다. +- 이번 라운드 반영 + - `useGisEdit.updateChangeGeometry()` 옵션에 `keepMoveToolActive`를 추가했다. + - `MOVE` `translateend`에서 geometry를 세션에 반영한 뒤 `{ keepMoveToolActive: true }`를 넘겨 `이동` 도구 active 상태를 유지한다. + - 같은 preview feature에 interaction이 계속 묶이지 않도록 `pendingInteraction`은 `null`로 비운다. + - 버튼 재클릭 off는 기존 `handleSelectGisEditTool()`의 동일 도구 클릭 분기를 그대로 사용한다. +- 검증 + - `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` 실행 결과, 저장소 기존 TypeScript 오류에서 실패했다. + - 이번 변경 파일인 `useGisEdit.ts` 관련 신규 오류는 출력되지 않았고, `page.tsx`는 기존 `GisApp.Module.layer` null 가능성 오류가 남아 있다. + - 실제 브라우저에서 관로 A/B 연속 이동과 버튼 재클릭 off 검증은 아직 필요하다. + +## 5. 기능별 현재 상태 + +### 5.1 `ADD` + +- 요구사항 + - 사용자가 지도 위에서 직접 점/선/면을 캡처해야 한다. +- 이전 상태 + - 지도 중심 기준 임시 geometry 자동 생성 +- 현재 상태 + - `Draw` interaction 기반으로 전환 중 +- 남은 과제 + - 선형/면형 종료 UX + - 캡처 취소 UX + - 세션 summary와 preview label 정교화 + - 신규 추가 색상은 레이어 메타 우선, 없으면 FE fallback 색으로 처리 + +### 5.2 `MOVE` + +- preview drag 방식으로 동작 확인 +- 유지 기준 + - 선택 객체 1건 기준 preview 생성 + - drag 종료 후 세션 geometry 갱신 +- 2026-05-15 보강 + - preview layer redraw와 interaction 재바인딩 타이밍을 안정화했다. + - 실제 브라우저 수동 drag 재확인은 아직 남아 있다. + +### 5.3 `VERTEX_EDIT` + +- 요구사항 + - 선택 객체의 vertex marker가 표시되어야 함 + - 양 끝점 이동 가능 + - 중간 vertex 이동 가능 +- 현재 리스크 + - 하수관로 geometry가 `LineString`/`MultiLineString`으로 섞여 들어온다. + - interaction 구현이 흔들리면 `MOVE`까지 회귀할 수 있다. +- 다음 기준 + - 하수관로를 기준으로 QGIS형 vertex handle 흐름부터 고정 +- 2026-05-15 보강 + - preview geometry와 별도인 vertex handle 레이어를 유지하되, 실꼭지점만 상시 표시하고 midpoint는 hover segment에 대해서만 노출하도록 재구현했다. + - endpoint drag, midpoint drag 후 vertex 삽입, geometry 재조립 흐름을 하나의 custom handle 규칙으로 통일했다. + - `하수처리시설`은 point라서 `VERTEX_EDIT` 대상이 아니라는 기준을 코드와 문서에 함께 맞췄다. + +### 5.4 `DELETE` + +- 임시 삭제 세션 흐름은 존재 +- 삭제 대상 시각 구분 강화는 후속 + +## 6. BE 공유 사항 + +- `GET /api/v1/map/gisEditLayers` + - geometry type/family + - editable 여부 + - allowed tools +- `POST /api/v1/map/gisEditSessions/save` + - 변경 유형별 geometry payload + - 원본 분리 저장 + +## 7. 남은 리스크 + +- `map/page.tsx`에 interaction 책임이 많이 몰려 있어 회귀 위험이 높다. +- `VERTEX_EDIT`는 하수관로 기준 단순화가 먼저 필요하다. +- `ADD`가 QGIS capture 경험과 완전히 같아지려면 종료/취소 UX를 더 다듬어야 한다. +- canvas 기반 `MOVE`, `VERTEX_EDIT`는 자동화 브라우저 검증이 제한적이라 실제 드래그 체감은 수동 확인이 남아 있다. + +## 8. 흡수한 기존 문서 + +- `001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md` +- `002-fe-SFR-012-003-하수관로편집-GIS편집상태재검토.md` +- `003-fe-SFR-012-003-하수관로편집-세션도구확장.md` +- `004-fe-SFR-012-003-하수관로편집-실제지도조작확장.md` +- `005-fe-SFR-012-003-하수관로편집-MOVE실드래그구현.md` +- `006-fe-SFR-012-003-하수관로편집-VERTEX_EDIT실조작구현.md` + +## 2026-06-10 관로 상세 조회 성능 개선 + +- 작업 목적 + - 하수관로 클릭 후 상세 팝업이 API 응답 전까지 비어 보이던 체감을 줄이고, 잘못된 상세 호출을 막는다. +- 프론트 변경 + - `featureClickEventPopup.tsx`에서 지도 클릭 feature 값으로 하수관로 기본정보를 먼저 렌더링하고, 상세 API 응답이 오면 기존 병합 흐름으로 덮어쓴다. + - 상세 식별값이 비어 있거나 `null`/`undefined` 문자열인 경우 `fcltppl` 상세 호출을 건너뛴다. + - `MsSwagePpl.tsx`의 운영이력 조회는 snake/camel 식별값을 모두 허용하고, 식별값이 없는 경우 호출하지 않도록 가드했다. +- 기대 효과 + - 관로 클릭 직후 팝업 기본정보가 즉시 표시된다. + - `/api/v1/fcltppl/null/null` 같은 불필요 호출을 줄여 상세 체감 지연과 백엔드 부하를 함께 낮춘다. +### 4.20 2026-06-26 관로 입력정보 조회 오류 방어 + +- 배경 + - MOVE on/off 동작 확인 중 `GisEditPipeSessionPopup`의 기존 관로 입력정보 조회에서 `Error: ERROR` 콘솔 오류가 발생했다. + - 해당 조회는 편집 세션 보조 입력정보를 채우기 위한 선택적 조회이므로, 실패해도 관로 이동/세션 편집 자체를 막지 않아야 한다. +- 반영 내용 + - `GisEditPipeSessionPopup`에 `isInvalidPipeLookupKey`를 추가해 빈 값, `-`, `NEW-*`, `null`, `undefined` 문자열이면 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}` 호출을 건너뛰도록 했다. + - 관리기관 목록과 관로 입력정보 조회 실패 catch에서 `console.error`를 제거해 Next 개발 콘솔 오버레이가 뜨지 않도록 했다. + - 조회 실패 시 기존 WFS 속성/세션 draft 값을 유지하고 팝업 입력 흐름은 계속 진행한다. +- 백엔드 연동 보강 + - `PplService.findByFcltMngNo(fcltMngNo, mngInstCd)`에서 MS 관로 상세 보조속성 조회와 관리기관명 조회를 안전 래퍼로 감싸, 보조 조회 실패가 `GET /api/v1/fcltppl/...` 전체 500으로 전파되지 않게 했다. + +### 4.21 2026-06-29 임시 관로 스냅 및 세션 버튼 재정렬 + +- 사용자 요구사항 기준 + - 기존 관로에는 잘 붙지만, 사용자가 임시로 추가한 관로에는 스냅핑이 제대로 동작하지 않는 문제가 있었다. + - `GIS 편집 세션` popup 버튼도 `취소`, `뒤로가기`, `초기화` 3종 의미로 다시 나눠야 했다. +- 원인 정리 + - pipe snap source는 현재 화면 BBOX 기준 `tb_ms_swage_ppl` 원본 WFS를 다시 읽는 구조라서, FE 세션 preview로만 존재하는 신규 관로/수정 관로가 source 우선 경로에서는 빠질 수 있었다. + - 세션 popup은 `마지막 변경 취소`, `초기화`만 있어 현재 선택한 change 1건만 제거하는 동작을 구분해 제공하지 못했다. +- 이번 라운드 반영 + - `page.tsx`에 `buildGisEditPipeSnapPreviewFeatures()`를 추가해 현재 세션의 하수관로 preview geometry를 별도 snap feature로 만들고, pipe snap source에 원본 WFS와 함께 병합했다. + - 기존 관로를 이동/꼭지점편집한 change는 원본 fid를 WFS snap source에서 제외해 수정 전 geometry에 다시 붙는 현상을 줄였다. + - pipe snap source는 WFS 응답 전에도 preview snap feature를 먼저 채워, 신규 관로를 추가한 직후 다른 신규 관로에 연속으로 스냅할 수 있게 했다. + - `useGisEdit`에 `removeFocusedChange()`를 추가해 현재 선택 change 제거를 지원하고, popup message type과 세션 패널 버튼도 `취소/뒤로가기/초기화` 기준으로 재정렬했다. + - 현재 상세 패널이 열려 있던 change가 세션에서 제거되면 우측 상세 패널도 함께 닫히도록 `page.tsx`의 detail effect를 보강했다. +- 남긴 판단 + - 이번 수정으로 임시 preview 관로가 snap 대상에서 빠지는 구조적 누락은 해소했지만, 실제 QGIS 체감과 지도 시각 피드백은 브라우저 수동 검증이 아직 필요하다. + - 세션 `취소`는 현재 focus된 change 기준이므로, 사용자가 어떤 change를 지우는지 목록 highlight와 지도 focus를 함께 보는 UX를 유지한다. + +### 4.22 2026-07-15 편집 세션 인라인 전환 및 도구바 재배치 + +- `map/page.tsx` + - GIS 편집 버튼에서 기존 세션 팝업 자동 열기를 제거하고 편집 모드만 토글하도록 변경했다. + - 편집 모드 또는 잔여 변경분이 있을 때 시설물 관리 오른쪽에 `GisEditSessionPanel`을 인라인으로 렌더링한다. + - 인라인 패널의 최대 높이를 `calc(50dvh - 82px)`로 제한해 시설물 접기 토글 영역을 피하고, 팝업 버튼은 기존 `handleOpenGisEditSessionPopup()`을 호출한다. + - 인라인/팝업의 변경분 선택은 `handleFocusGisEditSessionChange()`로 통합해 지도 포커스와 상세 패널 열기 흐름을 공유한다. +- `GisEditSessionPanel.tsx` + - `inline`/`popup` 변형과 선택적 `onOpenPopup`을 추가했다. + - 인라인 변형에 제목, 현재 레이어/도구, 명시적 `팝업` 버튼을 제공한다. + - 변경 목록 영역을 `flex-1` + `overflow-y-auto`로 구성해 항목 증가 시 패널이 최대 높이까지 늘고 이후 목록만 스크롤한다. +- `GisEditToolbar.tsx` + - 상단 위치를 `16px`에서 `84px`로 내려 검색바/세션과 겹치지 않도록 했다. + - 최대 너비를 `860px`에서 `760px`로 줄이고 중복 세션 버튼을 제거했다. +- `GisEditSessionPopupContent.tsx` + - 공용 세션 패널에 `variant="popup"`을 전달해 기존 팝업 제목/레이아웃을 유지한다. +- `useGisEdit.ts` + - 변경분 취소 과정에 남아 있던 깨진 한글 상태 메시지를 정상 문구로 복원했다. +- 호환성 + - `gisEditSessionPopup` 정의는 수정하지 않아 기존 `460 x 640` 크기와 위치, opener 메시지 계약을 유지한다. + - API, 저장 payload, 백엔드 코드는 변경하지 않았다. + +### 4.23 2026-07-15 사용자 화면 피드백에 따른 배치 및 양방향 전환 보정 + +- 피드백 + - 4.22 구현 화면에서 인라인 세션이 검색바 일부를 가리고, 도구바가 세션 아래가 아닌 중앙 검색바 아래에 표시됐다. + - 팝업을 열어도 인라인이 남았으며 팝업에서 화면 세션으로 돌아오는 기능이 필요했다. +- `map/page.tsx` + - `gisEditSessionView`를 `inline | popup`으로 관리해 두 뷰를 상호 배타적으로 렌더링한다. + - 인라인 세션과 `GisEditToolbar`를 `left: 278px`, `top: 16px`의 동일 플로팅 열에 넣고 도구바를 세션 바로 아래에 렌더링한다. + - 팝업 뷰에서는 인라인 카드 대신 초기 카드 높이만큼 자리 표시자를 유지해 도구바 위치가 갑자기 위로 이동하지 않게 했다. + - 인라인 표시 중 `searchBarClassName`으로 검색바를 `clamp(160px, 16vw, 220px)`만큼 오른쪽 이동한다. + - 팝업 열기 성공 시 `popup` 뷰로 전환하고, 팝업 복귀 메시지·직접 닫기 감시·세션 종료 시 `inline`으로 복원한다. +- `GisEditToolbar.tsx` + - `embedded` 변형을 추가해 절대 중앙 배치 대신 부모 플로팅 열 안에서 렌더링할 수 있게 했다. + - embedded 너비는 최대 `650px`로 제한했다. +- `Gis.js` + - 선택적 `searchBarClassName`을 추가해 `sn` 지도에서만 편집 세션 표시 중 검색바 위치를 보정한다. 기본값은 빈 문자열이다. +- 팝업 + - `GIS_EDIT_SESSION_SHOW_INLINE` 메시지를 추가했다. + - 팝업 헤더에 `화면에서 보기` 버튼을 추가해 opener에 복귀 메시지를 보낸다. + - 기존 팝업 크기와 위치, 세션 동기화/편집 명령 메시지는 유지한다. + +### 4.24 2026-07-15 파란 표시 영역 기준 최종 위치 보정 + +- 사용자 표시 해석 + - 상단 표시 영역은 축약형 인라인 편집 세션, 하단 표시 영역은 GIS 편집 도구바의 목표 위치다. + - 검색바는 이동하지 않는다. +- `GisEditSessionPanel.tsx` + - `variant="inline"`이고 변경분이 0건이면 `isCompactInline`을 적용한다. + - 축약 상태에서는 패딩을 줄이고 취소·뒤로가기·초기화 버튼과 빈 목록 안내를 숨긴다. + - 첫 변경분이 생기면 기존 작업 버튼과 변경 목록을 표시해 항목 수에 따라 확장한다. +- `map/page.tsx` + - 인라인 세션을 `left-[258px] top-4`, 너비 `360px`에 배치했다. + - 검색바 이동용 `searchBarClassName` 전달을 제거했다. + - 도구바를 세션 컨테이너 밖의 독립 오버레이로 복원했다. +- `GisEditToolbar.tsx` + - embedded 변형을 제거했다. + - 위치를 `bottom-12 left-1/2`, 최대 너비 `960px`로 변경해 하단 표시 영역에 고정했다. +- `Gis.js` + - 검색바 이동을 위해 추가했던 선택적 prop 변경을 모두 철회해 원본 상태로 복원했다. +- 팝업 + - 인라인 숨김, `화면에서 보기`, 직접 닫기 복원 동작은 유지한다. + +### 4.25 2026-07-15 인라인 편집 세션 너비 보정 + +- 사용자 실제 화면에서 `w-[360px]` 인라인 세션이 검색바 첫 선택 조건을 가리는 것을 확인했다. +- `map/page.tsx`의 인라인 세션 너비를 `w-[220px]`로 축소하고 불필요한 최대 너비 계산을 제거했다. +- 세션 위치·축약/확장·팝업 전환과 하단 GIS 편집 바는 변경하지 않았다. + +### 4.26 2026-07-15 인라인 세션 너비 여백 조정 + +- 사용자 화면 피드백에 따라 인라인 세션 너비를 `220px`에서 `260px`로 확대했다. +- 검색바 위치, 세션 좌표, 하단 도구바와 팝업 기능은 변경하지 않았다. + +### 4.27 2026-07-15 축약 세션 높이 통일 및 5px 확대 + +- `map/page.tsx` 인라인 세션 너비를 `260px`에서 `265px`로 변경했다. +- `GisEditSessionPanel.tsx`에서 변경분 0건인 inline 변형에 `h-[58px] p-2`를 적용했다. +- 변경분이 생긴 뒤에는 높이 고정을 적용하지 않는다. 검색바, 하단 도구바, 팝업 기능은 유지한다. + +### 4.28 2026-07-15 편집 세션 상단 정렬 10px 보정 + +- 사용자 화면에서 편집 세션이 검색바보다 약 10px 위에 표시되는 것을 확인했다. +- `map/page.tsx`의 세션 위치를 `top-4`에서 `top-[26px]`로 변경했다. +- 크기와 나머지 화면 동작은 변경하지 않았다. + +### 4.29 2026-07-15 변경 카드 한 줄 제목과 안내 요약 + +- `GisEditSessionPanel.tsx`의 카드 패딩을 `px-2 py-2`로 줄였다. +- 제목에 `whitespace-nowrap`, `text-[11px]`를 적용해 `편집유형 / 객체번호`를 한 줄로 표시한다. +- `지도 미리보기 선택` 배지는 `text-[9px]`, 축소 패딩, `whitespace-nowrap`으로 전체 문구를 유지한다. +- 선택 카드에서는 `정보저장` 배지를 숨겨 두 배지 동시 표시로 인한 가로 넘침을 방지한다. +- `getChangeSummary()`를 추가해 카드 하단 안내만 편집 유형별 짧은 문구로 표시했다. 내부 `change.summary`와 상태 메시지는 변경하지 않았다. + +### 4.30 2026-07-15 한 줄 안내 문구 친절도 보정 + +- `getChangeSummary()`의 완료형 문구를 짧은 행동 안내형으로 변경했다. +- 추가 위치 확인, 이동 위치 조정, 점 이동/선분 추가, 삭제 대상 확인, 상세정보 확인을 한 줄 길이로 제공한다. +- 카드 레이아웃과 원본 `change.summary` 데이터는 변경하지 않았다. + +### 4.31 2026-07-15 GIS 편집 종료 시 세션 UI 동시 숨김 + +- `map/page.tsx`의 `shouldShowGisEditSession`을 변경분 존재 여부가 아닌 `gisEdit.isEditMode`만 사용하도록 변경했다. +- 편집 종료 시 `closeGisEditSessionPopup()`을 호출해 열린 세션 팝업도 닫는다. +- 편집 재진입 시 `gisEditSessionView`를 `inline`으로 복원한다. +- 팝업 sync effect도 편집 모드가 꺼져 있으면 실행하지 않는다. +- `useGisEdit`의 변경분 배열은 유지하므로 다시 진입하면 기존 변경 카드가 복원된다. diff --git a/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md new file mode 100644 index 000000000..554bced1c --- /dev/null +++ b/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md @@ -0,0 +1,89 @@ +# SN RDII분석결과 지도연계 유지보수 FE 통합구현기록 + +## 1. 작업 목적 +- RDII 분석결과를 좌측 LNB 대신 하수관망도 `분석도구` popup으로 옮기고, 침입수 분석결과와 같은 지도 연계 흐름으로 정리한다. + +## 2. 실제 수정 파일 +- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/rdiiResult/**` +- `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- `FE_APP_ROOT/src/features/windowPopup/**` +- `FE_APP_ROOT/src/app/styles/components.css` + +## 3. 구현 내용 +- `rdiiResultPopup` popup 정의와 registry를 추가했다. +- `RdiiResultPopupPage`를 만들어 기존 `ii/rdiiResult`의 조회/다운로드 흐름을 popup에서 재사용하도록 옮겼다. +- row click 시 `SN_RDII_RESULT_ROW_SELECT` 메시지를 부모 창으로 전달하고, `useRdiiResultMap`에서 `/sewerage-service/api/v1/rdiiAnlsRslt/mapView/{rainInfwtAnlsSn}`를 호출해 결과 관로 overlay를 렌더하도록 연결했다. +- row click 직후 지도 이동은 `SN_INFTWT_PROC_SEARCH` 검색바 동기화 이벤트를 재사용해 시도/시군구 중심 이동 규칙을 그대로 따르도록 맞췄다. +- overlay 관로 click 시 `rdiiChart` popup을 `resultOnly` 모드로 열고, `rainInfwtAnlsSn/aplcnYm/analysisInfo`를 함께 넘겨 좌측 선택조건 없이 결과만 보이도록 확장했다. +- `분석도구` 메뉴에는 popup key를 `rdiiResultPopup`으로 연결하되, 실제 노출 라벨은 `RDII 분석`으로 맞췄고 legacy `ii/rdiiResult`는 CSS로만 숨겨 route는 유지했다. + +## 4. 검증 +- FE 변경분 기준 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`을 실행했다. +- 이번 변경으로 새로 생긴 오류는 없었고, 아래 선행 타입 오류 때문에 전체 통과는 하지 못했다. + - `app/(map-layout)/map/components/common/index.ts` + - `app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` + - `app/(map-layout)/map/page.tsx` + - `app/html/(map-layout)/map/components/common/index.ts` + - `app/html/(with-base-layout)/layout.tsx` + - `packages/common/business_components/src/ui/chart/*` + +## 5. 2026-07-02 후속 UI 보정 +- 사용자의 피드백에 따라 침입수 분석 메뉴 아이콘은 기존 물방울 표시로 되돌렸다. +- RDII 분석결과 메뉴 문구는 실제 노출 기준 RDII 분석으로 조정했다. + +## 6. 남은 이슈 +- 브라우저 실동작 검증은 이번 세션에서 수행하지 못했다. +- LNB 숨김 규칙은 현재 메뉴 순서 기준 `nth-child(4)`를 사용하므로, 추후 I/I 메뉴 순서가 바뀌면 selector를 함께 조정해야 한다. +## 7. 2026-07-02 resultOnly popup 후속 보정 +- `rdiiChart` resultOnly 우측 요약 카드의 제목 라벨을 `shrink-0 + whitespace-nowrap` 기준으로 보정해 `강우유입수량 분석결과`가 한 줄로 유지되도록 맞췄다. +- 차트/요약 조회 Promise 묶음에 예외 방어를 추가해 조회 실패 시 콘솔 오류를 남기고 상태를 빈 값으로 명확히 초기화하도록 정리했다. +- FE가 계속 호출하던 `/api/v1/rdiiAnlsRslt/ppl/{rainInfwtAnlsSn}/{pplMngNo}`, `/api/v1/rdiiAnlsRslt/ppl/{rainInfwtAnlsSn}` 경로가 BE에 실제로 필요함을 기준으로 연동 계약을 다시 점검했다. +- `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json` 재실행 결과, 이번 변경과 무관한 기존 선행 오류만 남고 RDII 변경 파일 기준 신규 오류는 확인되지 않았다. +## 8. 최종 확인 메모 +- 사용자 실동작 기준으로 `분석도구 > RDII 분석` popup, row click 지도 이동, 관로 highlight, resultOnly popup 흐름이 정상 동작하는 것으로 마감한다. +- FE 전체 타입 오류는 이번 RDII 변경과 무관한 기존 선행 이슈만 남아 별도 정리 대상으로 유지한다. +## 9. 2026-07-09 분석 화면 결과보기 버튼 연계 + +- `ii/BaseLayout.tsx` 공통 액션 영역에 `결과보기` 버튼 슬롯을 추가하고, `ii/rdii/page.tsx`에서 `rdiiResultPopup` 전용 진입 함수를 연결했다. +- `ii/analysisResultView.ts`는 `/map?analysisPopupKey=rdiiResultPopup` 창과 RDII 결과 popup을 같은 사용자 클릭에서 함께 연다. +- `RdiiResultPopupPage` row click은 storage 이벤트용 payload를 함께 남기고, `useRdiiResultMap`은 storage listener를 추가해 opener가 map이 아닌 경우에도 highlight 흐름을 이어가게 했다. +- `map/page.tsx`와 `AnalysisPopupMenu.tsx`는 query 기준으로 `RDII 분석결과` 메뉴 활성 상태를 맞춘다. +- 같은 TypeScript 검증에서 이번 RDII 결과보기 버튼 연계 파일 기준 신규 오류는 확인되지 않았다. + +## 10. 2026-07-09 버튼 순서 미세조정 + +- 사용자 요청에 따라 RDII 분석 화면 액션 버튼 순서를 `양식 받기 -> RDII분석 실행 -> 결과보기`로 조정했다. +- 변경은 공통 `ii/BaseLayout.tsx` 렌더 순서만 수정해 침입수 분석 화면과 동일한 배치 규칙을 사용하도록 맞췄다. + +## 11. 2026-07-09 RDII 분석 완료 alert 구현 + +- `ii/rdii/page.tsx`는 `savewithfile` 성공 직후 `분석 요청이 접수되었습니다. 완료 시 안내됩니다.` 문구를 먼저 보여주고, 응답의 `rainInfwtAnlsSn`으로 완료 감시를 시작하도록 바꿨다. +- 감시는 공통 `ii/useAnalysisCompletionWatcher.ts`를 재사용해 `GET /api/v1/rdiiAnls/{rainInfwtAnlsSn}/status` polling과 `Y/N` 종료 alert를 처리한다. +- `app/(with-base-layout)/layout.tsx`에 `GlobalAnalysisCompletionWatcher`를 mount해 사용자가 RDII 화면을 떠난 뒤에도 같은 레이아웃 범위 안에서는 완료 alert가 이어지도록 보강했다. +- FE 정적 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 RDII 완료 alert 변경 파일 기준 신규 오류는 없고 기존 선행 오류만 남았다. + +## 12. 2026-07-13 RDII 분석 progress overlay 이관 + +- `ii/rdii/page.tsx`의 완료 watcher 등록에 `analysisType=RDII`, `analysisName=RDII 분석`, 초기 `10% REQUEST_ACCEPTED` 상태를 추가했다. +- 공통 `useAnalysisCompletionWatcher.tsx`는 overlay 허용 분석유형을 `INFTWT`, `RDII`로 확장해 기존 좌측 하단 UI와 localStorage 감시 흐름을 그대로 재사용한다. +- RDII status API의 `progressPercent`, `progressStep`, `progressMessage`, `updatedAt`을 기존 공통 polling 로직이 받아 화면에 반영한다. +- TypeScript 전체 검사는 기존 지도·HTML·공통 차트 선행 오류로 실패했으며, 이번 RDII 변경 파일에서 새 오류는 확인되지 않았다. +- 실제 브라우저에서 RDII 실행 후 단계 전환과 페이지 이동 중 overlay 유지 여부를 수동 확인해야 한다. + +## 13. 2026-07-13 RDII 분석정보 헤더/내용 줄바꿈 보정 + +- 지도 `RDII 분석결과` 팝업의 `anlsInfo` 헤더를 `RDII\n분석정보`로 변경했다. +- 공통 `UiTable`의 `white-space: pre-line` 렌더링을 재사용해 `RDII`와 `분석정보`가 두 줄로 표시되도록 맞췄다. +- 긴 분석정보 값이 다음 열과 겹치지 않도록 RDII `UiTable`의 `anlsInfo` 데이터 셀에 침입수와 동일한 `whitespace-normal break-all leading-[1.35]` 클래스를 적용했다. +- 레거시 `ii/rdiiResult` 화면과 공통 테이블 컴포넌트는 수정하지 않았다. +- TypeScript 전체 검사는 기존 선행 오류로 실패했으며, `RdiiResultPopupPage.tsx` 관련 신규 오류는 없었다. + +## 14. 2026-07-13 최종 코드 재검토 및 마감 + +- RDII 분석 실행 후 watcher 등록값, 전역 overlay의 `INFTWT/RDII` 필터, status 응답 반영과 `Y/N` 종료 alert 흐름을 다시 대조했다. +- RDII 분석결과 팝업은 헤더 줄바꿈과 `anlsInfo` 데이터 셀의 `break-all` 처리가 침입수 팝업 패턴과 동일함을 확인했다. +- 최종 TypeScript 전체 검사는 기존 지도·HTML·공통 차트 선행 오류로 실패했으며, RDII 변경 파일의 신규 오류는 없었다. +- `git diff --check`를 통과했고 자동 검증 기준 추가 수정이 필요한 프론트 결함은 발견되지 않아 구현 범위를 마감한다. +- 실제 단계 전환과 긴 분석정보 표시 상태는 배포 환경 브라우저 smoke test 항목으로 남긴다. diff --git a/apps/sn/docs/작업내용/SN-정보검색-하수도정비기본계획보고서-유지보수/001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md b/apps/sn/docs/작업내용/SN-정보검색-하수도정비기본계획보고서-유지보수/001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md new file mode 100644 index 000000000..a0a6ea34b --- /dev/null +++ b/apps/sn/docs/작업내용/SN-정보검색-하수도정비기본계획보고서-유지보수/001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md @@ -0,0 +1,82 @@ +# 정보검색-하수도정비기본계획보고서 통합구현기록 + +## 1. 문서 목적 + +- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 구현 내용을 기록한다. + +## 2. 입력 근거 + +- 공통 설계: `IIPBS/docs/작업계획/SN-정보검색-하수도정비기본계획보고서-유지보수/001-de-정보검색-하수도정비기본계획보고서-설계.md` +- 사용자 요구사항 + - report 탭 조회결과를 기존 영역에 표시 + - 파일명 클릭 즉시 다운로드 + - 조회조건 순서 변경 + - 조회조건 한 줄 정렬 및 여백 보정 + - 탭 전환 시 기본조회 + - select 포커스 잘림 보정 + +## 3. 공통 계약 + +- 기존 `dc-service` 프록시 계약 `clsf`, `file`, `fileDownload`를 그대로 사용한다. +- `sewerage-service` 신규 API와 DB 변경은 이번 범위에서 수행하지 않는다. +- 공통 컴포넌트 수정은 `sn` 요구사항을 위한 옵션 추가 또는 스타일 보정으로만 한정한다. + +## 4. 구현 라운드 요약 + +### 4.1 report 탭 인라인 복구 + +- `SearchModal`에서 `report` 탭 선택 시 전용 팝업을 자동으로 열던 흐름을 제거했다. +- 안내 카드 대신 `ReportTab`을 같은 본문 영역에 직접 렌더링하도록 바꿨다. + +### 4.2 파일명 직접 다운로드 전환 + +- `ReportTab`의 셀 클릭을 `파일명` 컬럼으로 한정했다. +- 파일이 있는 행의 `파일명` 셀에 파란 글씨, 밑줄, 포인터 스타일을 적용했다. +- 파일 클릭 시 `dc-service` 파일 다운로드를 바로 호출하도록 연결했다. +- 다건 파일은 현재 구조상 대표 파일 정의가 필요해서 첫 번째 파일을 대표 다운로드 대상으로 사용했다. +- 표시 문자열은 `1건이면 실제 파일명`, `2건 이상이면 첫 파일명 외 N건`으로 보정했다. + +### 4.3 조회조건 재배치 및 필터 보강 + +- 조회조건 표시 순서를 `시도 > 시군구 > 승인년도 > 구분 > 보고서 구분`으로 맞췄다. +- 기존에 조회 UI에 없던 `구분(plantZoneGubun)` 필터를 추가했다. +- 승인년도는 `0`, `9999` 같은 placeholder 값을 실제 필터에서 제외하도록 정리했다. + +### 4.4 탭 기본조회 보강 + +- 팝업 최초 진입 시 1회 자동조회만 하던 흐름을 탭 전환 기준으로 보강했다. +- `기본 정보`, `유지관리 정보`는 탭 또는 대표 조건이 바뀔 때 기본조건 조회가 다시 돌도록 `SearchModal/useSearchModal` 공통 흐름을 정리했다. +- `하수도 정비 기본계획 보고서`는 `ReportTab` 내부에서 탭 진입 시 1회 자동조회되도록 유지했다. + +### 4.5 조회조건 레이아웃 및 결과 영역 정리 + +- 보고서 탭 조회조건은 한 줄 우선 정렬로 재배치하고, 화면이 좁을 때는 줄바꿈 대신 가로 스크롤이 나도록 바꿨다. +- 조회조건 박스의 과한 최소 높이와 하단 여백을 줄여 빈 줄처럼 보이던 공간을 제거했다. +- 결과 영역은 고정 높이 wrapper를 제거하고 `UiTable` 자체 레이아웃을 사용하게 정리해 상하 공백을 줄였다. +- 최종 보정으로 조회조건 각 요소 간 간격을 조금 더 벌려 보이도록 조정했다. + +### 4.6 행 클릭 제한 및 공통 select 포커스 보정 + +- 보고서 표는 이제 행 전체가 아니라 `파일명` 컬럼만 클릭되도록 `UiTable`에 `clickableHeadIds` 옵션을 추가했다. +- 이 옵션은 기본 미지정 시 기존 동작을 유지하므로 다른 화면에 영향이 없다. +- 공통 `SelectBox`는 포커스 outline이 위에서 잘리지 않도록 root에 `overflow-visible`과 세로 여유를 주고, trigger 높이를 조금 늘렸다. +- 추가로 포커스 ring을 바깥이 아닌 안쪽(`ring-inset`)으로 바꿔 기본정보/유지관리/보고서 탭 모두에서 잘림 없이 보이도록 보정했다. + +## 5. 수정 파일 + +- `frontend/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx` +- `frontend/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx` +- `frontend/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts` +- `frontend/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts` +- `frontend/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx` +- `frontend/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx` +- `frontend/packages/common/business_components/src/ui/table/index.tsx` +- `frontend/packages/common/business_components/src/ui/select/select.css` +- `frontend/packages/common/business_components/src/ui/select/lib/selectBox.tsx` + +## 6. 남은 리스크 + +- 다건 파일 대표 다운로드 기준은 현재 첫 번째 파일 기준이다. +- `reportFiles` 윈도우 팝업 코드는 현재 미호출 상태로 남아 있다. +- lint/build 자동 검증은 워크스페이스 pnpm 환경 문제로 완료하지 못했다. +- 브라우저에서 최신 select 포커스 스타일과 자동조회 체감은 추가 수동 확인이 필요하다. diff --git a/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md new file mode 100644 index 000000000..f1228deca --- /dev/null +++ b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md @@ -0,0 +1,173 @@ +# SN 침입수분석결과 지도연계 유지보수 FE 통합구현기록 + +## 1. 작업 목적 + +- 하수관망도 `분석도구`에 `침입수 분석` popup을 추가하고, 기존 `ii/result` 결과 조회 흐름을 지도 중심 popup으로 옮긴다. +- 결과 row 선택 시 지도에서 처리구역과 결과 관로를 강조하고, `실측하수량` 보유 관로는 별도 색상으로 구분한다. +- 결과 관로 클릭 시 기존 관로 상세 대신 `침입수 분석결과` 전용 popup을 재사용한다. + +## 2. 실제 수정 파일 + +- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts` +- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts` +- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` +- `FE_APP_ROOT/src/features/windowPopup/registry.tsx` +- `FE_APP_ROOT/src/features/windowPopup/ui/InfilChartPopupContent.tsx` +- `FE_APP_ROOT/src/features/windowPopup/ui/InftwtResultPopupContent.tsx` + +## 3. 구현 내용 + +- `AnalysisPopupMenu`에 `침입수 분석` 메뉴를 추가했다. +- window popup 체계에 `inftwtResultPopup`을 등록했다. +- 신규 `InftwtResultPopupPage`를 추가해 기존 침입수 분석이력 조회와 결과 다운로드를 popup 안에서 수행하도록 구성했다. +- 결과 row 클릭 시 `SN_INFTWT_RESULT_ROW_SELECT` message를 부모창으로 전달하도록 구현했다. +- `useInftwtResultMap` hook을 추가해: + - 신규 `mapView` endpoint 호출 + - 처리구역 geometry overlay + - 결과 관로 overlay + - `실측하수량` 보유 관로 주황색 강조 + - overlay 관로 클릭 시 결과 전용 `infilChart` popup 분기 + - 를 한 곳에서 처리하게 했다. +- 기존 `infilChart`는 그대로 두지 않고 `resultOnly` 모드를 추가했다. + - 기본 모드: 기존처럼 사용자가 상수사용월/분석정보를 선택 + - 결과 전용 모드: `inftwtAnlsSn`, `aplcnYm`, `analysisInfo`, `pplMngNo`를 받아 좌측 조건 없이 결과만 표시 + +## 4. 지도 연계 기준 + +- 현재 구현은 처리분구 geometry 원천이 명확하지 않아 `처리구역` geometry를 우선 focus source로 사용한다. +- 처리구역 geometry가 없으면 결과 관로 extent로 fallback 한다. +- 일반 결과 관로는 파란색, `실측하수량` 보유 관로는 주황색으로 표시한다. + +- 후속 UI 보정으로 `침입수 분석` 메뉴를 분석도구 최하단으로 이동했고, popup 조회조건의 `상수사용기간`은 기존 `DateMonthPicker` 패턴으로 교체했으며, 하수처리시설은 텍스트 입력 대신 기존 `CtpvSggPrcsFcltSelectBox` 기반 선택으로 바꿨다. +- 지도 이동 안정화를 위해 결과 row 선택 후에는 처리구역 첫 feature id 또는 첫 번째 결과 관로 fid를 기존 `showSearchResultsFromGeoJSON` 경로로 우선 사용하고, overlay geometry는 GeoJSON `crs` 값을 읽어 동적으로 projection을 해석하도록 보강했다. + +## 5. 검증' + +- 코드 기준 정적 확인 + - popup 정의/registry 연결 확인 + - `map/page.tsx` message listener 및 overlay click 분기 확인 +- 자동 검증 + - FE TypeScript 검증은 저장소 `pnpm` lockfile 정책 문제로 수행하지 못했다. + - 오류 내용: `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` + +## 6. 남은 이슈 + +- 실제 브라우저에서 popup row 선택 후 지도 focus, 색상 구분, overlay 관로 click 동작을 수동으로 확인해야 한다. +- `양평` 데이터 기준 `실측하수량` 관로가 기대대로 주황색으로 분리되는지 검증이 필요하다. +- 처리분구 geometry 원천이 확정되면 `useInftwtResultMap` 내부 focus 규칙을 확장해야 한다. + + +## 7. 2026-07-01 속도/안정화 보정 + +- `useInftwtResultMap`이 `focusLayerType`, `focusFid`를 우선 사용해 기존 `showSearchResultsFromGeoJSON` 이동 경로를 재사용하도록 보정했다. +- 결과 관로 하이라이트는 백엔드가 조합한 dedup `pplMngNo` 기준 GeoJSON만 사용하고, 실패 시에만 관로 extent fallback을 수행한다. +- FE 정적 검증은 로컬 `tsc` 실행까지 시도했지만 `apps/sn/tsconfig.json`의 `ignoreDeprecations` 값이 현재 TypeScript 버전과 맞지 않아 미완료였다. + +## 8. 2026-07-01 row click 오동작 보정 + +- `useInftwtResultMap`의 지도 이동 기준을 `showSearchResultsFromGeoJSON` 선택 흐름에서 overlay extent fit 기준으로 바꿨다. +- `focusTargetType=ZONE`이면 처리구역 extent를 우선 사용하고, 아니면 결과 관로 extent를 사용하도록 수정했다. +- 이 보정으로 row click 직후 잘못된 기본 관로 상세가 자동으로 열리는 현상을 줄인다. + +## 9. 2026-07-01 분석 실행 화면 선택구조 보정 + +- `ii/BaseLayout.tsx`에 `selectionComponent` 주입 지점을 추가해, 화면별로 기본 시설 selector를 교체할 수 있게 했다. +- `ii/irsetting/page.tsx`는 새 `CtpvSggSwtrmtZoneSelectBox`를 사용하도록 바꿨다. +- 새 selector는 `하수처리구역명`을 먼저 선택하고, 대응 `하수처리시설명`은 읽기 전용으로 자동 표시한다. +- 선택 검증 유틸은 기존 로직을 유지하되 `treatmentPlantLabel` 옵션만 추가해, `irsetting`에서는 안내 문구가 `하수처리구역을 선택해주세요.`로 나오게 맞췄다. +- FE 타입 검증은 `tsc --ignoreDeprecations 5.0` 기준 새 변경 파일 오류 없이 통과했고, 남은 오류는 기존 지도/공통 차트 파일 선행 이슈로 분리했다. +## 10. 2026-07-01 분석 실행 500 디버깅 보강 +- postMutiPart는 Axios 500 응답의 본문 메시지를 추출해 상위 화면으로 다시 던지도록 바꿨다. +- ii/irsetting은 자동 표시된 시설명, 매칭결과 배열, 시간패턴 배열이 비어 있으면 서버 호출 전에 사용자에게 즉시 알린다. +## 11. 2026-07-01 분석도구 popup 조회조건 처리구역 우선 전환 +- InftwtResultPopupPage의 조회조건 selector도 CtpvSggSwtrmtZoneSelectBox로 교체했다. +- 팝업 목록 조회는 저장 구조를 유지하기 위해 여전히 swtrmtFcltNm으로 필터링하되, 사용자가 선택하는 순서는 하수처리구역명 -> 하수처리시설명 자동 표시로 맞췄다. +## 12. 2026-07-01 popup 하이라이트 렌더 보강 +- useInftwtResultMap은 처리구역 focus 시 현재 처리구역 extent 안에 결과 관로가 하나도 없으면 결과 관로 extent로 자동 fallback 하도록 보강했다. +- zone/pipe source와 layer에 changed, setVisible(true), enderSync를 호출해 overlay가 바로 그려지도록 보강했다. +- 브라우저 콘솔에는 침입수 FE overlay 렌더 결과 로그를 남겨 zone/pipe feature count를 확인할 수 있게 했다. +## 13. 2026-07-01 시설명 기준 복귀와 시도/시군구 조회 이동 +- InftwtResultPopupPage 조회조건은 처리구역 selector를 제거하고 다시 CtpvSggPrcsFcltSelectBox 기반 하수처리시설명 선택 구조로 복귀했다. +- 팝업 다운로드 버튼은 상단 헤더가 아니라 조회 버튼 옆으로 이동시켜, 실제 결과 조회와 내려받기 동선을 한 줄에 맞췄다. +- row click 직후 지도는 처리구역 extent fit 대신 SN_INFTWT_PROC_SEARCH 이벤트로 메인 상단 검색바의 시도/시군구/시설명 상태를 동기화하고, 검색바와 같은 시도/시군구 중심 이동 규칙을 우선 사용한다. +- 처리구역 우선 selector 실험용 CtpvSggSwtrmtZoneSelectBox.tsx는 현재 구현에서 더 이상 참조되지 않아 정리했다. +## 14. 2026-07-01 I/I 분석 legacy 결과 탭 임시 숨김 +- 사용자 요청 기준으로 좌측 `I/I 분석` 메뉴의 `침입수 분석결과` 항목은 현재 화면에서 숨겼다. +- 숨김 방식은 공통 LNB 데이터 구조를 다시 건드리지 않고, `FE_APP_ROOT/src/app/styles/components.css`에서 해당 서브메뉴 항목만 표시하지 않도록 제한했다. +- 이 보정은 메뉴 노출만 감추는 수준이며, 기존 `ii/result` route와 화면 코드는 유지한다. +- 따라서 추후 필요 시 스타일 규칙만 제거하면 기존 업무형 결과 화면 진입선을 다시 살릴 수 있다. +## 15. 2026-07-09 분석 화면 결과보기 버튼 연계 + +- `ii/BaseLayout.tsx`에 `결과보기` 버튼 슬롯을 추가하고, `ii/irsetting/page.tsx`에서 `inftwtResultPopup` 전용 진입 함수를 연결했다. +- 새 helper `ii/analysisResultView.ts`는 사용자 클릭 1회에서 `/map?analysisPopupKey=inftwtResultPopup` 창과 결과 popup을 함께 열어 popup 차단 가능성을 줄인다. +- `InftwtResultPopupPage` row click은 기존 opener postMessage 외에 `localStorage`에도 선택 row를 기록하고, `useInftwtResultMap`은 storage 이벤트를 함께 구독해 map 창이 opener가 아니어도 지도 연계를 이어가게 했다. +- `map/page.tsx`와 `AnalysisPopupMenu.tsx`는 `analysisPopupKey`가 있을 때 `침입수 분석결과` 메뉴를 활성 상태로 보여 주도록 보강했다. +- `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json` 재실행 결과, 이번 변경 파일 기준 신규 오류는 없고 기존 선행 오류만 남았다. + +## 16. 2026-07-09 버튼 순서 미세조정 + +- 사용자 요청에 따라 침입수 분석 화면 액션 버튼 순서를 `양식 받기 -> 침입수분석 실행 -> 결과보기`로 조정했다. +- 변경은 공통 `ii/BaseLayout.tsx` 렌더 순서만 수정해 RDII 분석 화면에도 같은 순서가 적용되도록 맞췄다. + +## 17. 2026-07-09 실측하수량 관로 우선 줌인 보정 + +- 침입수 분석결과 popup에서 row click 시 `oilqPipeCount > 0`이면 시도/시군구 중심 이동보다 `hasOilq` 관로 extent fit을 우선 적용하도록 `useInftwtResultMap`을 보정했다. +- 실측하수량 관로가 없는 row만 기존 지역 중심 이동과 일반 결과 관로 fallback을 유지한다. +- 이 보정으로 주황색으로 표시되는 실측하수량 관로가 있는 케이스는 지도 진입 직후 해당 관로 주변으로 바로 확대된다. + +## 18. 2026-07-09 지역 선이동 제거 보정 + +- `useInftwtResultMap`은 row click 직후 mapView를 먼저 확보한 뒤, `oilqPipeCount > 0`이면 검색바 동기화 이벤트에 `suppressMapMove`를 내려 지역 중심 이동을 건너뛰도록 보정했다. +- `procSearchBar`는 `suppressMapMove`가 내려오면 시도/시군구/시설 상태만 갱신하고 `moveMapToCenter`는 호출하지 않는다. +- 이 보정으로 실측하수량 관로가 있는 케이스는 처음부터 해당 관로 extent로 바로 확대된다. + +## 19. 2026-07-09 분석 대상지역 기본값 보정 + +- `ii/irsetting`, `ii/rdii`의 기본 선택값을 `경기도 / 양평군 / 양평`으로 맞췄다. +- 공통 `CtpvSggPrcsFcltSelectBox`는 mount 시 부모 값을 강제로 `0`으로 되돌리던 초기화 흐름을 제거하고, 시설 목록 로딩 후 `defaultSwtrmtFcltNm` 이름 기준으로 기본 시설을 자동 선택하게 보강했다. +- 이 보정으로 두 분석 화면 진입 직후 `하수처리시설명` select도 `양평` 기준으로 바로 보이도록 정리했다. +## 20. 2026-07-09 침입수 분석 완료 alert 구현 + +- `ii/irsetting/page.tsx`는 더 이상 `savewithfile` 즉시 응답을 `분석 완료`로 간주하지 않는다. +- 제출 성공 시 문구를 `분석 요청이 접수되었습니다. 완료 시 안내됩니다.`로 바꾸고, 응답으로 받은 `inftwtAnlsSn` 기준으로 상태 감시를 시작한다. +- 신규 hook `ii/useAnalysisCompletionWatcher.ts`를 추가해 `GET /api/v1/inftwtAnls/{inftwtAnlsSn}/status` polling과 `Y/N` 종료 alert를 공통 처리한다. +- 상태 추적 번호가 없으면 즉시 사용자에게 후속 결과목록 확인 안내를 띄운다. +- FE 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 변경 파일이 아닌 기존 선행 오류만 남았다. + +## 21. 2026-07-10 침입수 분석 전역 progress overlay 구현 + +- `ii/useAnalysisCompletionWatcher.tsx`로 파일을 옮기고, 기존 완료 alert watcher를 `localStorage` 기반 전역 progress watcher + 좌측 하단 overlay 렌더 구조로 확장했다. +- `SN_PENDING_ANALYSIS_WATCHES` item에는 `analysisType`, `analysisName`, `progressPercent`, `progressStep`, `progressMessage`, `updatedAt`를 함께 저장해 페이지 이동 뒤에도 같은 진행 상태를 다시 그릴 수 있게 했다. +- 전역 watcher mount는 `src/app/providers/app-provider.tsx` 하나로 유지하고, `with-base-layout/layout.tsx`의 중복 mount는 제거해 같은 창에서 polling이 두 번 돌지 않도록 정리했다. +- `ii/irsetting/page.tsx`는 `analysisType=INFTWT`, 초기 `10% / REQUEST_ACCEPTED / 요청 접수 완료` 상태로 감시를 시작하면서도, 접수 alert 문구는 사용자 요구대로 `분석 데이터가 성공적으로 제출되었습니다.`를 그대로 유지한다. +- FE 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 변경 파일 신규 오류는 제거되었으며 남은 실패는 기존 선행 오류만 확인됐다. + +## 22. 2026-07-13 프로그래스바 최종 표시 방식 정리 + +- 팝업창에서는 progress overlay를 숨기고 메인 화면에서만 보이도록 최종 정리했다. +- `status` polling 응답은 공용 wrapper `{ status, message, data }`를 해제해 실제 `data.progressPercent`를 읽도록 보강했다. +- 최종 화면 표시는 보간이 아니라 단계값 고정형으로 정리했다. + - `10%` 요청 접수 완료 + - `20%` 상세 데이터 저장 시작 + - `30%` 상세 데이터 저장 완료 + - `40%` SWMM 분석 실행 중 + - `80%` 결과 저장 중 + - `100%` 완료 +- 이 정리로 사용자는 오래 걸리는 두 구간을 `40%`, `80%`부터 보게 되어, 막판 `99%` 고정처럼 보이는 체감을 줄일 수 있다. + +## 23. 2026-07-13 프로그래스바 구현 마감 정리 + +- 전역 progress overlay는 메인 화면에서만 보이고 `/popup/` 경로에서는 표시하지 않도록 마감했다. +- 최종 표시 방식은 보간 없이 고정 단계값 기준으로 정리했다. + - `10%` 요청 접수 완료 + - `20%` 상세 데이터 저장 시작 + - `30%` 상세 데이터 저장 완료 + - `40%` SWMM 분석 실행 중 + - `80%` 결과 저장 중 + - `100%` 완료 +- `status` polling은 공용 wrapper `{ status, message, data }`를 해제해 실제 `data.progressPercent`를 읽도록 보강했고, `_ts` query와 `cache: no-store`를 적용해 캐시된 상태 응답이 재사용되지 않게 했다. +- 진행상태 카드 폭/색상/팝업 중복 노출도 함께 정리했다. diff --git a/apps/sn/middleware.ts b/apps/sn/middleware.ts index c4da97544..f0a6733d6 100644 --- a/apps/sn/middleware.ts +++ b/apps/sn/middleware.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from "next/server"; const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; -const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || ""; -const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || ""; +const SERVER_API_URL = process.env.SERVER_API_URL || ""; +const SEWERAGE_API_URL = process.env.SEWERAGE_API_URL || ""; const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || ""; const LX_GEOSERVER_URL = process.env.NEXT_PUBLIC_LX_GEOSERVER_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 { diff --git a/apps/sn/package.json b/apps/sn/package.json index 5b89e93db..8cfd172aa 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -10,9 +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:*", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", @@ -28,12 +30,14 @@ "js-cookie": "^3.0.5", "moment": "^2.30.1", "next": "15.5.18", + "lucide-react": "^0.474.0", "react-chartjs-2": "^5.3.0", "react-hot-toast": "^2.5.2", "react-dnd": "^16.0.1", "react-hook-form": "^7.54.2", "tailwindcss-animate": "^1.0.7", "tus-js-client": "^4.3.1", + "uuid": "^14.0.1", "xlsx": "^0.18.5", "xlsx-populate": "^1.21.0", "zod": "^3.24.2", diff --git a/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.png b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.png new file mode 100644 index 000000000..073f396c1 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.png differ diff --git a/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.svg b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.svg new file mode 100644 index 000000000..cc160ef47 --- /dev/null +++ b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_BLUE.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_ORANGE.png b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_ORANGE.png new file mode 100644 index 000000000..4e0c046ce Binary files /dev/null and b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_ORANGE.png differ diff --git a/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_ORANGE.svg b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_ORANGE.svg new file mode 100644 index 000000000..db569fe19 --- /dev/null +++ b/apps/sn/public/images/map/layericon/SN_SINKHOLE_SPOT_ORANGE.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/sn/src/app/providers/app-provider.tsx b/apps/sn/src/app/providers/app-provider.tsx index 81821a635..654acb9c4 100644 --- a/apps/sn/src/app/providers/app-provider.tsx +++ b/apps/sn/src/app/providers/app-provider.tsx @@ -11,7 +11,7 @@ import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; - +import { GlobalAnalysisCompletionWatcher } from "../../../app/(with-base-layout)/ii/useAnalysisCompletionWatcher"; const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); @@ -29,6 +29,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { {children} + @@ -43,4 +44,4 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { ); }; -export { AppProvider }; +export { AppProvider }; \ No newline at end of file diff --git a/apps/sn/src/app/styles/components-black.css b/apps/sn/src/app/styles/components-black.css index 4dc30bafd..d6378dad4 100644 --- a/apps/sn/src/app/styles/components-black.css +++ b/apps/sn/src/app/styles/components-black.css @@ -2,28 +2,19 @@ @layer components { .card { - @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label; } .card-border { - @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + @apply border border-border bg-background shadow-blur; } .card-header { - @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4; } .card-footer { - @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; - } - - [data-theme="black"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4; } input[type="file"] { - @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground; } } diff --git a/apps/sn/src/app/styles/components-blue.css b/apps/sn/src/app/styles/components-blue.css index 7c1e9db27..d6378dad4 100644 --- a/apps/sn/src/app/styles/components-blue.css +++ b/apps/sn/src/app/styles/components-blue.css @@ -2,28 +2,19 @@ @layer components { .card { - @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label; } .card-border { - @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + @apply border border-border bg-background shadow-blur; } .card-header { - @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4; } .card-footer { - @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; - } - - [data-theme="blue"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4; } input[type="file"] { - @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground; } } diff --git a/apps/sn/src/app/styles/components-green.css b/apps/sn/src/app/styles/components-green.css index 4b4cc770e..d6378dad4 100644 --- a/apps/sn/src/app/styles/components-green.css +++ b/apps/sn/src/app/styles/components-green.css @@ -2,28 +2,19 @@ @layer components { .card { - @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label; } .card-border { - @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + @apply border border-border bg-background shadow-blur; } .card-header { - @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4; } .card-footer { - @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; - } - - [data-theme="green"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4; } input[type="file"] { - @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground; } } diff --git a/apps/sn/src/app/styles/components-red.css b/apps/sn/src/app/styles/components-red.css index cadffac52..d6378dad4 100644 --- a/apps/sn/src/app/styles/components-red.css +++ b/apps/sn/src/app/styles/components-red.css @@ -2,28 +2,19 @@ @layer components { .card { - @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label; } .card-border { - @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + @apply border border-border bg-background shadow-blur; } .card-header { - @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4; } .card-footer { - @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; - } - - [data-theme="red"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4; } input[type="file"] { - @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground; } } diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css index 3aa3c770d..a92f6ad27 100644 --- a/apps/sn/src/app/styles/components.css +++ b/apps/sn/src/app/styles/components.css @@ -2,19 +2,200 @@ @layer components { .card { - @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666]; + @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label; } .card-border { - @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + @apply border border-border bg-background shadow-blur; } .card-header { - @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; + @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4; } .card-footer { - @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; + @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4; } input[type="file"] { - @apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer; + @apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground; + } + + .sn-popup-page { + @apply flex h-screen min-w-0 flex-col overflow-hidden bg-background text-foreground; + } + + .sn-popup-modal-page { + @apply flex h-screen items-start justify-center bg-container px-3 py-3 text-foreground; + } + + .sn-popup-shell { + @apply w-full overflow-hidden rounded-[12px] border border-border bg-background; + } + + .sn-popup-modal-shell { + @apply w-full overflow-hidden rounded-[12px] border border-border bg-background; + } + + .sn-popup-header { + @apply flex min-h-[44px] items-center justify-between border-b border-border bg-primary px-5 py-3; + } + + .sn-popup-modal-header { + @apply flex h-[44px] items-center border-b border-border bg-secondary px-[18px]; + } + + .sn-popup-title { + @apply text-[1.45rem] font-semibold text-primary-foreground; + } + + .sn-popup-modal-title { + @apply text-[1.35rem] font-semibold tracking-[-0.01em] text-foreground; + } + + .sn-popup-header-link { + @apply mr-3 text-[1.05rem] font-medium text-primary-foreground hover:text-primary-foreground; + opacity: 0.9; + } + + .sn-popup-header-button { + @apply border px-3 py-1 text-[1.05rem] text-primary-foreground; + border-color: color-mix(in srgb, var(--primary-foreground) 30%, transparent); + } + + .sn-popup-section { + @apply flex min-h-0 flex-1 flex-col overflow-hidden p-4; + } + + .sn-popup-filter-panel { + @apply grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4; + } + + .sn-popup-filter-panel-compact { + @apply border-b border-border bg-secondary pb-3; + } + + .sn-popup-field { + @apply block min-w-0; + } + + .sn-popup-field-label { + @apply mb-1 text-[1.05rem] font-medium text-label; + } + + .sn-popup-input { + @apply h-10 w-full rounded-md border border-input bg-field px-3 text-[1.05rem] text-foreground outline-none placeholder:text-placeholder focus:ring-2 focus:ring-ring; + } + + .sn-popup-input-sm { + @apply h-[32px] rounded-[6px] border border-input bg-field px-[10px] text-[1.02rem] font-medium text-foreground outline-none transition placeholder:text-placeholder focus:ring-2 focus:ring-ring; + } + + .sn-popup-actions { + @apply flex flex-wrap items-end justify-end gap-2; + } + + .sn-popup-primary-button { + @apply h-10 px-5 !text-[1.05rem]; + } + + .sn-popup-secondary-button { + @apply h-10 rounded-md border border-input bg-background px-5 text-[1.05rem] text-foreground hover:bg-accent; + } + + .sn-popup-secondary-button-sm { + @apply h-9 rounded-md border border-input bg-background px-4 text-[1.05rem] text-foreground hover:bg-accent; + } + + .sn-popup-danger-button-sm { + @apply h-9 rounded-md border border-destructive bg-background px-4 text-[1.05rem] text-destructive; + } + + .sn-popup-danger-button-sm:hover { + background-color: color-mix(in srgb, var(--destructive) 10%, var(--background)); + } + + .sn-popup-primary-button-sm { + @apply h-[32px] min-w-[56px] rounded-sm bg-primary px-0 py-2 text-sm font-medium text-primary-foreground hover:opacity-90; + } + + .sn-popup-status-bar { + @apply border-b border-border bg-secondary px-5 py-2 text-[1rem] text-label; + } + + .sn-popup-success-bar { + @apply border-b border-border bg-secondary px-5 py-2 text-[1rem] text-foreground; + } + + .sn-popup-card { + @apply overflow-hidden rounded-[10px] border border-border bg-background; + } + + .sn-popup-panel-title { + @apply text-[1.05rem] font-medium tracking-[-0.01em] text-label; + } + + .sn-popup-grid-table { + @apply w-full border-collapse text-[1.05rem]; + } + + .sn-popup-grid-head-row { + @apply bg-secondary text-title; + } + + .sn-popup-grid-head-row-strong { + @apply bg-muted text-foreground; + } + + .sn-popup-grid-head-cell { + @apply border border-border px-2 py-2; + } + + .sn-popup-grid-head-cell-sub { + @apply border border-border px-2 py-1; + } + + .sn-popup-grid-cell { + @apply border border-border px-2 py-2 text-foreground; + } + + .sn-popup-grid-row { + @apply bg-background; + } + + .sn-popup-grid-row-hover { + } + + .sn-popup-grid-row-hover:hover { + background-color: color-mix(in srgb, var(--secondary) 60%, var(--background)); + } + + .sn-popup-grid-row-selected { + @apply bg-primary-transparent; + } + + .sn-popup-empty-cell { + @apply text-label; + } + + .sn-popup-badge { + @apply inline-flex items-center justify-center px-2 py-1 text-[12px] font-semibold; + } + + .sn-popup-badge-danger { + @apply bg-destructive text-destructive-foreground; + } + + .sn-popup-badge-normal { + @apply border border-border bg-background text-foreground; + } + + .sn-popup-help-text { + @apply mt-2 rounded-md border border-border bg-secondary px-4 py-3 text-[1.05rem] text-label; } } +/* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */ +/* `I/I 분석 > 침입수 분석결과`, `RDII 분석결과`는 지도 popup 흐름으로 대체하고 레거시 라우트는 유지한 채 LNB에서만 숨긴다. */ +[data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(2) { + display: none; +} +[data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(4) { + display: none; +} diff --git a/apps/sn/src/features/auth/login/index.ts b/apps/sn/src/auth/login/index.ts similarity index 100% rename from apps/sn/src/features/auth/login/index.ts rename to apps/sn/src/auth/login/index.ts diff --git a/apps/sn/src/auth/login/ui/login-page.tsx b/apps/sn/src/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..c41ca5f0a --- /dev/null +++ b/apps/sn/src/auth/login/ui/login-page.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; + +import { authService } from "@/entities/auth"; +import { images } from "@common/assets"; + +import "./login.css"; + +function LoginPage() { + const [errorMessage, setErrorMessage] = useState(""); + + const handleSubmit = async (e: React.SubmitEvent) => { + 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 ( +
+
+
+

+ 유역하수도통합플랫폼 +
+ 하수도통계 관리시스템 +

+

+ 유역하수도통합플랫폼 하수도통계 관리시스템에 오신 것을 + 환영합니다. +

+
+
+ logo +
+
+ +
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+ {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 + 계) 032-590-3723, 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; diff --git a/apps/fo/src/features/auth/login/ui/login.css b/apps/sn/src/auth/login/ui/login.css similarity index 94% rename from apps/fo/src/features/auth/login/ui/login.css rename to apps/sn/src/auth/login/ui/login.css index af73c9c68..e65180ae7 100644 --- a/apps/fo/src/features/auth/login/ui/login.css +++ b/apps/sn/src/auth/login/ui/login.css @@ -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; diff --git a/apps/sn/src/entities/auth/api/auth-service.ts b/apps/sn/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..93b158ef9 --- /dev/null +++ b/apps/sn/src/entities/auth/api/auth-service.ts @@ -0,0 +1,115 @@ +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, +} from "@/shared/config"; +import { getCookie } from "@/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 "../../../store/tab-store"; + +async function login(payload: AuthPayload): Promise { + 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 { + 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 { + tokenManager.clearAuthToken(); + clearDeviceId(); + + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} + +export const authService: AuthService = { + login, + silentRefresh, + declineSessionExtend: clearRefreshTimer, + logout, +}; diff --git a/apps/sn/src/entities/auth/api/login-service.ts b/apps/sn/src/entities/auth/api/login-service.ts deleted file mode 100644 index 4d7a7cd8d..000000000 --- a/apps/sn/src/entities/auth/api/login-service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { AxiosError } from "axios"; -import { - ENDPOINT, - JWT_EXPIRATION_TIME, - ACCESS_TOKEN, - AUTH_USER_ID, - CLAIM_NAME, - SITE_ID, -} 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 { - try { - tokenManager.clearAuthToken(); - const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Site-Id": SITE_ID, - }, - 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 { - try { - const response = await fetch( - `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, - { - method: "PUT", - headers: { - "X-Site-Id": SITE_ID, - }, - }, - ); - - 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(); diff --git a/apps/sn/src/entities/auth/index.ts b/apps/sn/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/sn/src/entities/auth/index.ts +++ b/apps/sn/src/entities/auth/index.ts @@ -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"; diff --git a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx index f37ff4041..534e1c776 100644 --- a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx @@ -1,71 +1,88 @@ "use client"; import { useEffect } from "react"; -import { usePathname } from "next/navigation"; -import { client } from "@/shared/api"; -import type { IUserInfo } from "@/shared/store"; -import { useUserStore } from "@/shared/lib"; -import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; -import { isAtLoginRedirectUrl } from "@common/network"; -import { getCookie } from "@/shared/lib"; -import { tokenManager } from "./token-manager"; +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 "@/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 { 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 pathname = usePathname(); - 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; - } + 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 userId = getCookie(AUTH_USER_ID); - if (!userId) { - throw new Error("Missing required authentication tokens"); - } + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return; - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/info/me`, - ); - if (!userInfo) setUserInfo(response.data); - } + async function authCheck() { + const userId = getToken(AUTH_USER_ID); + if (!userId) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + return; + } - useEffect(() => { - (async () => { - try { - await authCheck(); - } catch (error) { - console.error(error); - - setUserInfo(null); + if (userInfo) return; - if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return; + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + setUserInfo(response.data); + } - // const hostname = window.location.hostname; + authCheck().catch((error) => { + // 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨 + if (isAxiosError(error) && error.response?.status === 401) return; - // // 개발 환경에서는 localhost 도메인 사용 - // if ( - // hostname === "localhost" || - // hostname === "127.0.0.1" || - // hostname === "con.mind-one.co.kr" - // ) - // window.location.href = "/"; + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + }); + }, []); - // // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - // // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용 - // if (hostname.includes("hasuon.or.kr")) - // window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트 + // silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동 + useEffect(() => { + function handleAuthLogout() { + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + } - // 개발/운영 환경에서 로그인 URL 분기는 환경 변수로 처리 (.env.*) - window.location.assign(LOGIN_PAGE_URL); - } - })(); - }, [pathname]); + window.addEventListener("auth:logout", handleAuthLogout); + return () => + window.removeEventListener("auth:logout", handleAuthLogout); + }, []); - return <>{children}; + return <>{children}; }; export { AuthWrapper }; diff --git a/apps/sn/src/entities/auth/lib/device-id.ts b/apps/sn/src/entities/auth/lib/device-id.ts new file mode 100644 index 000000000..f844fcbef --- /dev/null +++ b/apps/sn/src/entities/auth/lib/device-id.ts @@ -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); +} diff --git a/apps/sn/src/entities/auth/lib/token-manager.ts b/apps/sn/src/entities/auth/lib/token-manager.ts index 5b24c8bd3..83affbcaf 100644 --- a/apps/sn/src/entities/auth/lib/token-manager.ts +++ b/apps/sn/src/entities/auth/lib/token-manager.ts @@ -1,127 +1,15 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; -import { getCookie, setCookie, removeCookie } from "@/shared/lib";; +import { authConfig } from "@/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 { - // 환경에 따른 도메인 설정 - //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 { - removeCookie(key); - } - - public setupRefreshTimer( - callback: () => Promise, - 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; diff --git a/apps/sn/src/entities/menu-authority/index.ts b/apps/sn/src/entities/menu-authority/index.ts index 2a555b77e..d5991d01c 100644 --- a/apps/sn/src/entities/menu-authority/index.ts +++ b/apps/sn/src/entities/menu-authority/index.ts @@ -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"; diff --git a/apps/sn/src/entities/menu/api/menu-service.ts b/apps/sn/src/entities/menu/api/menu-service.ts index 4424f8d37..02a2d1718 100644 --- a/apps/sn/src/entities/menu/api/menu-service.ts +++ b/apps/sn/src/entities/menu/api/menu-service.ts @@ -11,4 +11,4 @@ export class MenuApiService implements MenuService { } } -export const MenuApi = new MenuApiService(); +export const MenuApi = new MenuApiService(); \ No newline at end of file diff --git a/apps/sn/src/entities/menu/index.ts b/apps/sn/src/entities/menu/index.ts index 04f63e38b..2bd1b1787 100644 --- a/apps/sn/src/entities/menu/index.ts +++ b/apps/sn/src/entities/menu/index.ts @@ -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"; diff --git a/apps/sn/src/entities/user/api/user-service.ts b/apps/sn/src/entities/user/api/user-service.ts new file mode 100644 index 000000000..482d51e16 --- /dev/null +++ b/apps/sn/src/entities/user/api/user-service.ts @@ -0,0 +1,225 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + UserService, + UserSearchParams, + UserList, + UserDetailResponse, + UserSttsStatsList, + UserUpdateData, + UserPasswordUpdateData, + UserFindPasswordUpdateData, + UserPasswordMatchData, + UserUpdateInfoData, + UserSaveData, + UserFindPasswordSaveData, + UserVerifyData, + UserEmailData, + UserIdData, + AdminUserList, +} from "../model/user-interface"; + +export class UserApiService implements UserService { + // 사용자 목록 조회 + public async findPageUser(params: UserSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}`, + { params }, + ); + return response.data; + } + + // 사용자 단건 조회 + public async findByUserUnqId(userUnqId: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + ); + return response.data; + } + + // 로그인한 사용자 정보 조회 + public async findByUserUnqIdByLogin(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + return response.data; + } + + // 사용자 회원상태 통계 조회 + public async selectUserSttsStats(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/stts/stats`, + ); + return response.data; + } + + // 사용자 저장 + public async saveUser(data: UserSaveData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}`, + data, + ); + return response.data; + } + + // 사용자 수정 + public async updateUser( + userUnqId: string, + data: UserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 삭제 + public async deleteUser(userUnqId: string): Promise { + const response = await client.delete( + `${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 처리 + public async lockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 해제처리 + public async unlockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`, + ); + return response.data; + } + + // 리프레시 토큰 정보 재발급 + public async refreshToken(): Promise { + await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`); + } + + // 사용자 비밀번호 변경 + public async updatePassword(data: UserPasswordUpdateData): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/update`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 초기화(관리자용) + public async initializePassword( + userUnqId: string, + data: UserPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 변경 + public async changePassword( + data: UserFindPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/change`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 확인 + public async matchPassword(data: UserPasswordMatchData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/match`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 + public async findPasswordUser( + data: UserFindPasswordSaveData, + ): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/find`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 유효성 확인 + public async validPassword(token: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`, + ); + return response.data; + } + + // 사용자 회원정보 변경 + public async updateInfo( + userUnqId: string, + data: UserUpdateInfoData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 승인처리 + public async applyUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`, + ); + return response.data; + } + + // 사용자 회원탈퇴 + public async leaveUser(data: UserVerifyData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/leave`, + data, + ); + return response.data; + } + + // 사용자 이메일 중복 체크 + public async existsEmail(data: UserEmailData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + data, + ); + return response.data; + } + + // 사용자 아이디 중복 체크 + public async existsUserId(data: UserIdData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + data, + ); + return response.data; + } + + // 관리자 목록 조회 + public async findPageAdminUser( + params: UserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/admin/list`, + { params }, + ); + return response.data; + } +} + +export const UserApi = new UserApiService(); diff --git a/apps/sn/src/entities/user/index.ts b/apps/sn/src/entities/user/index.ts new file mode 100644 index 000000000..8927256f9 --- /dev/null +++ b/apps/sn/src/entities/user/index.ts @@ -0,0 +1,17 @@ +export { UserApi } from "./api/user-service"; +export { + USER_STATE_CODE, + userSaveSchema, + userPasswordUpdateSchema, + userUpdateSchema, + type UserStateCode, + type UserSearchParams, + type UserListResponse, + type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, + type UserUpdateData, + type AdminUserList, + type AdminUserListResponse, +} from "./model/user-interface"; +export { PasswordResetDialog } from "./ui/password-reset-dialog"; diff --git a/apps/sn/src/entities/user/model/user-interface.ts b/apps/sn/src/entities/user/model/user-interface.ts new file mode 100644 index 000000000..012fe9e92 --- /dev/null +++ b/apps/sn/src/entities/user/model/user-interface.ts @@ -0,0 +1,362 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export const USER_STATE_CODE = { + 대기: "00", + 정상: "01", + 정지: "07", + 탈퇴: "08", + 삭제: "09", +} as const; + +export type UserStateCode = + (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; + +// 사용자 검색 파라미터 +export interface UserSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + userStateCode?: string; + aprvYn?: string; + grpId?: string; + frDateYmd?: string; + toDateYmd?: string; + userName?: string; +} + +// 사용자 목록 응답 인터페이스 +export interface UserListResponse { + userId: string; + userName: string; + email: string; + roleId: string; + roleName: string; + userStateCode: UserStateCode; + userStateCodeName: string; + lastLoginDate: string; + loginFailCount: number; + userUnqId: string; + mphn: string; + telno: string; + aprvYn: string; + regDt: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + aprvYmd: string; +} + +// 사용자 상세 응답 인터페이스 +export interface UserDetailResponse { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: UserStateCode; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; + twoFactorEnabled: boolean; + adminRoleYn: string; + instSeCd: string; + aprvYmd: string; + enoCd: string; + dtlInstCd: string; + laeSeNm: string; + ogdpSeNm: string; + instSeNm: string; + userStateNm: string; + pswdHntNm: string; + ecoNm: string; + dtlInstNm: string; +} + +// 사용자 상태 통계 응답 인터페이스 +export interface UserSttsStatsResponse { + sttsCd: string; + sttsNm: string; + cnt: number; +} + +// 관리자 목록 응답 인터페이스 +export interface AdminUserListResponse { + userUnqId: string; + userId: string; + userName: string; + groupId: string; + groupNm: string; + userStateCode: string; + userStateCodeName: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + dtlInstCd: string; + dtlInstNm: string; + mdfr: string; + mdfrNm: string; + mdfcnDt: string; +} + +// 사용자 수정 요청 스키마 +export const userUpdateSchema = z.object({ + userId: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + roleId: z.string().optional(), + userStateCode: z.string(), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + aprvYn: z.string(), + autzrId: z.string().uuid().optional(), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + aprvYmd: z.string().max(8).optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 변경 스키마 +export const userPasswordUpdateSchema = z + .object({ + password: z.string().optional(), + provider: z.string().optional(), + token: z.string().optional(), + newPassword: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + newPasswordConfirm: z.string(), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: "비밀번호가 일치하지 않습니다.", + path: ["newPasswordConfirm"], + }); + +// 사용자 비밀번호 찾기 변경 스키마 +export const userFindPasswordUpdateSchema = z.object({ + tokenValue: z.string(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 비밀번호 확인 스키마 +export const userPasswordMatchSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 회원정보 변경 스키마 +export const userUpdateInfoSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), +}); + +// 사용자 저장 스키마 +export const userSaveSchema = z.object({ + userId: z.string(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 찾기 저장 스키마 +export const userFindPasswordSaveSchema = z.object({ + userName: z.string(), + emailAddr: z.string().email(), + mainUrl: z.string(), + changePasswordUrl: z.string(), +}); + +// 사용자 인증 요청 스키마 +export const userVerifySchema = z.object({ + password: z + .string() + .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), +}); + +// 사용자 이메일 요청 스키마 +export const userEmailSchema = z.object({ + userId: z.string().optional(), + email: z.string().email(), +}); + +// 사용자 ID 요청 스키마 +export const userIdSchema = z.object({ + userId: z.string(), +}); + +// Zod 스키마 타입 정의 +export type UserUpdateData = z.infer; +export type UserPasswordUpdateData = z.infer; +export type UserFindPasswordUpdateData = z.infer< + typeof userFindPasswordUpdateSchema +>; +export type UserPasswordMatchData = z.infer; +export type UserUpdateInfoData = z.infer; +export type UserSaveData = z.infer; +export type UserFindPasswordSaveData = z.infer< + typeof userFindPasswordSaveSchema +>; +export type UserVerifyData = z.infer; +export type UserEmailData = z.infer; +export type UserIdData = z.infer; + +// 페이지네이션 타입 정의 +export type UserList = PageableList; +export type UserSttsStatsList = UserSttsStatsResponse[]; +export type AdminUserList = PageableList; + +// 사용자 서비스 인터페이스 +export interface UserService { + // 사용자 목록 조회 + findPageUser: (params: UserSearchParams) => Promise; + + // 사용자 단건 조회 + findByUserUnqId: (userUnqId: string) => Promise; + + // 로그인한 사용자 정보 조회 + findByUserUnqIdByLogin: () => Promise; + + // 사용자 회원상태 통계 조회 + selectUserSttsStats: () => Promise; + + // 사용자 저장 + saveUser: (data: UserSaveData) => Promise; + + // 사용자 수정 + updateUser: (userUnqId: string, data: UserUpdateData) => Promise; + + // 사용자 삭제 + deleteUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 처리 + lockUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 해제처리 + unlockUser: (userUnqId: string) => Promise; + + // 리프레시 토큰 정보 재발급 + refreshToken: () => Promise; + + // 사용자 비밀번호 변경 + updatePassword: (data: UserPasswordUpdateData) => Promise; + + // 사용자 비밀번호 초기화(관리자용) + initializePassword: ( + userUnqId: string, + data: UserPasswordUpdateData, + ) => Promise; + + // 사용자 비밀번호 찾기 변경 + changePassword: (data: UserFindPasswordUpdateData) => Promise; + + // 사용자 비밀번호 확인 + matchPassword: (data: UserPasswordMatchData) => Promise; + + // 사용자 비밀번호 찾기 + findPasswordUser: (data: UserFindPasswordSaveData) => Promise; + + // 사용자 비밀번호 찾기 유효성 확인 + validPassword: (token: string) => Promise; + + // 사용자 회원정보 변경 + updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise; + + // 사용자 승인처리 + applyUser: (userUnqId: string) => Promise; + + // 사용자 회원탈퇴 + leaveUser: (data: UserVerifyData) => Promise; + + // 사용자 이메일 중복 체크 + existsEmail: (data: UserEmailData) => Promise; + + // 사용자 아이디 중복 체크 + existsUserId: (data: UserIdData) => Promise; + + // 관리자 목록 조회 + findPageAdminUser: (params: UserSearchParams) => Promise; +} diff --git a/apps/sn/src/entities/user/ui/password-reset-dialog.tsx b/apps/sn/src/entities/user/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..548e8fa1a --- /dev/null +++ b/apps/sn/src/entities/user/ui/password-reset-dialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + DialogTrigger, +} from "@common/components"; +import { + UserApi, + userPasswordUpdateSchema, + type UserPasswordUpdateData, +} from "@/entities/user"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/ui/form"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; + +interface PasswordResetDialogProps { + userUnqId: string; + children: ReactNode; +} + +export function PasswordResetDialog({ + userUnqId, + children, +}: PasswordResetDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + provider: "password", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다.", () => { + setOpen(false); + form.reset(); + }); + }, + }); + + return ( + + {children} + + + 비밀번호 초기화 + +
+ onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/sn/src/features/auth/login/ui/login-page.tsx b/apps/sn/src/features/auth/login/ui/login-page.tsx deleted file mode 100644 index dea3c90f1..000000000 --- a/apps/sn/src/features/auth/login/ui/login-page.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { useEffect } from "react"; -import { images } from "@common/assets"; -import { LoginApi } from "@/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) => { - 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 = "/map"; - }; - - return ( -
-
-
-

- 하수도정보시스템 -
- CMS 통합관리시스템 -

-

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

-
-
- logo -
-
-
-
-
    -
  • -

    ID

    -
    - - -
    -
  • -
  • -

    PASSWORD

    -
    - - -
    -
  • -
-
-
- - -
- {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} -
- -
-
-

copyright © 2023 KECO. All rights reserved.

-
- -

- - 주소 : (22689) 인천광역시 서구 환경로 42(경서동 - 종합환경연구단지) - - - 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, - 3722 - - 팩스 :032-590-3729 -

-
-
- -
- ); -} - -export { LoginPage }; - diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts index dbb90794a..892d065d4 100644 --- a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -3,7 +3,7 @@ import { windowPopupDefinitions, type WindowPopupKey, } from "../model/popupDefinitions"; -import type { OpenWindowPopupParams } from "../model/types"; +import type { OpenWindowPopupParams, WindowPopupDefinition } from "../model/types"; const buildQueryString = (params: OpenWindowPopupParams = {}) => { const searchParams = new URLSearchParams(); @@ -17,17 +17,59 @@ const buildQueryString = (params: OpenWindowPopupParams = {}) => { return queryString ? `?${queryString}` : ""; }; -const buildPopupFeatures = (width: number, height: number) => { +const clamp = (value: number, min: number, max: number) => { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +}; + +const buildPopupFeatures = ({ + width, + height, + placement = "center", + alignTop = false, + offsetLeft = 0, + offsetTop = 0, +}: Pick< + WindowPopupDefinition, + "width" | "height" | "placement" | "alignTop" | "offsetLeft" | "offsetTop" +>) => { const screenLeft = window.screenX ?? window.screenLeft ?? 0; const screenTop = window.screenY ?? window.screenTop ?? 0; const outerWidth = window.outerWidth || window.screen.width; const outerHeight = window.outerHeight || window.screen.height; - const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2)); - const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2)); + const screen = window.screen as Screen & { + availLeft?: number; + availTop?: number; + }; + const availableLeft = screen.availLeft ?? 0; + const availableTop = screen.availTop ?? 0; + const availableWidth = window.screen.availWidth || outerWidth; + const availableHeight = window.screen.availHeight || outerHeight; + const safeWidth = Math.min(width, Math.max(360, availableWidth - 48)); + const safeHeight = Math.min(height, Math.max(420, availableHeight - 72)); + const minLeft = availableLeft; + const minTop = availableTop + 24; + const maxLeft = availableLeft + availableWidth - safeWidth - 16; + const maxTop = availableTop + availableHeight - safeHeight - 24; + const left = + placement === "browser-left" + ? clamp(screenLeft + offsetLeft, minLeft, maxLeft) + : clamp( + screenLeft + Math.round((outerWidth - safeWidth) / 2) + offsetLeft, + availableLeft + 16, + maxLeft, + ); + const top = clamp( + alignTop + ? screenTop + 24 + offsetTop + : screenTop + Math.round((outerHeight - safeHeight) / 2) + offsetTop, + minTop, + maxTop, + ); return [ - `width=${width}`, - `height=${height}`, + `width=${safeWidth}`, + `height=${safeHeight}`, `left=${left}`, `top=${top}`, "resizable=yes", @@ -48,7 +90,7 @@ export const openWindowPopup = ( const definition = windowPopupDefinitions[popupKey]; const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; - const popupFeatures = buildPopupFeatures(definition.width, definition.height); + const popupFeatures = buildPopupFeatures(definition); const popupWindow = window.open( popupUrl, `sn-popup-${popupKey}`, diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index fe2ee66ba..744ccfe8a 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -46,18 +46,38 @@ export const windowPopupDefinitions = { width: 1280, height: 980, }, + inftwtResultPopup: { + key: "inftwtResultPopup", + title: "침입수 분석결과", + width: 1280, + height: 900, + }, + rdiiResultPopup: { + key: "rdiiResultPopup", + title: "RDII 분석결과", + width: 1280, + height: 900, + }, infilChart: { key: "infilChart", title: "침입수 분석", - width: 1360, - height: 980, + width: 940, + height: 860, + placement: "browser-left", + alignTop: true, + offsetLeft: 0, + offsetTop: 70, requiredParams: ["pplMngNo"], }, rdiiChart: { key: "rdiiChart", title: "RDII 분석", - width: 1360, - height: 980, + width: 940, + height: 860, + placement: "browser-left", + alignTop: true, + offsetLeft: 0, + offsetTop: 70, requiredParams: ["pplMngNo"], }, sscDetail: { @@ -70,8 +90,18 @@ export const windowPopupDefinitions = { agedPipePopup: { key: "agedPipePopup", title: "노후 하수관로 관리 ", - width: 1180, - height: 760, + width: 850, + height: 600, + placement: "browser-left", + alignTop: true, + offsetLeft: 0, + offsetTop: 200, + }, + sinkholePopup: { + key: "sinkholePopup", + title: "지반침하발생현황", + width: 1380, + height: 820, }, pipeAnalysisPopup: { key: "pipeAnalysisPopup", @@ -94,8 +124,8 @@ export const windowPopupDefinitions = { floodHistoryPopup: { key: "floodHistoryPopup", title: "침수발생이력", - width: 1080, - height: 760, + width: 1160, + height: 820, }, gisEditPopup: { key: "gisEditPopup", @@ -103,16 +133,23 @@ export const windowPopupDefinitions = { width: 1080, height: 760, }, + gisEditSessionPopup: { + key: "gisEditSessionPopup", + title: "편집 세션", + width: 460, + height: 640, + placement: "browser-left", + }, gisManagementPopup: { key: "gisManagementPopup", title: "GIS 관리", - width: 1080, - height: 760, + width: 860, + height: 700, }, flowRiskPopup: { key: "flowRiskPopup", title: "통수 위험", - width: 1080, + width: 920, height: 760, }, } as const satisfies Record; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 8387e03c1..cd07c5119 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -14,6 +14,10 @@ export type WindowPopupDefinition = { title: string; width: number; height: number; + placement?: "center" | "browser-left"; + alignTop?: boolean; + offsetLeft?: number; + offsetTop?: number; requiredParams?: string[]; }; @@ -31,6 +35,21 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple", + GIS_EDIT_SESSION_READY: "sn:popup:gisEditSessionReady", + GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync", + GIS_EDIT_SESSION_SHOW_INLINE: "sn:popup:gisEditSessionShowInline", + GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange", + GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE: "sn:popup:gisEditSessionRemoveFocusedChange", + GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange", + GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges", + WATER_RISK_READY: "sn:popup:waterRiskReady", + WATER_RISK_SYNC: "sn:popup:waterRiskSync", + WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult", + WATER_RISK_START_SETTING_EDIT: "sn:popup:waterRiskStartSettingEdit", + WATER_RISK_STOP_SETTING_EDIT: "sn:popup:waterRiskStopSettingEdit", + WATER_RISK_REMOVE_STAGED_PIPE: "sn:popup:waterRiskRemoveStagedPipe", + WATER_RISK_REORDER_STAGED_PIPE: "sn:popup:waterRiskReorderStagedPipe", + WATER_RISK_CLEAR_STAGED_PIPES: "sn:popup:waterRiskClearStagedPipes", } as const; export type WindowPopupMessageType = diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index c76d9064f..fe6758bf2 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -4,44 +4,35 @@ import AgedPipePopupContent from "./ui/AgedPipePopupContent"; import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent"; import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent"; import InfilChartPopupContent from "./ui/InfilChartPopupContent"; +import InftwtResultPopupContent from "./ui/InftwtResultPopupContent"; import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent"; import OldPpTablePopupContent from "./ui/OldPpTablePopupContent"; import RdiiChartPopupContent from "./ui/RdiiChartPopupContent"; +import RdiiResultPopupContent from "./ui/RdiiResultPopupContent"; import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; import ReportTabPopupContent from "./ui/ReportTabPopupContent"; import SearchModalPopupContent from "./ui/SearchModalPopupContent"; +import SinkholePopupContent from "./ui/SinkholePopupContent"; import SscDetailPopupContent from "./ui/SscDetailPopupContent"; import type { WindowPopupContentProps } from "./model/types"; import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import FloodHistoryPopupContent from "./ui/FloodHistoryPopupContent"; +import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent"; +import GisManagementPopupContent from "./ui/GisManagementPopupContent"; import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; +import WaterRiskPopupContent from "./ui/WaterRiskPopupContent"; -const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => ( +const FloodPredictionPlaceholderPopupContent = (_props: WindowPopupContentProps) => ( ); -const FloodHistoryPopupContent = (_props: WindowPopupContentProps) => ( - -); - const GisEditPopupContent = (_props: WindowPopupContentProps) => ( ); -const GisManagementPopupContent = (_props: WindowPopupContentProps) => ( - -); - -const FlowRiskPopupContent = (_props: WindowPopupContentProps) => ( - -); - export const windowPopupRegistry = { oldPpUpload: { ...windowPopupDefinitions.oldPpUpload, @@ -71,6 +62,14 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.mapSearch, component: SearchModalPopupContent, }, + inftwtResultPopup: { + ...windowPopupDefinitions.inftwtResultPopup, + component: InftwtResultPopupContent, + }, + rdiiResultPopup: { + ...windowPopupDefinitions.rdiiResultPopup, + component: RdiiResultPopupContent, + }, infilChart: { ...windowPopupDefinitions.infilChart, component: InfilChartPopupContent, @@ -87,6 +86,10 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.agedPipePopup, component: AgedPipePopupContent, }, + sinkholePopup: { + ...windowPopupDefinitions.sinkholePopup, + component: SinkholePopupContent, + }, pipeAnalysisPopup: { ...windowPopupDefinitions.pipeAnalysisPopup, component: PipeAnalysisPopupContent, @@ -97,7 +100,7 @@ export const windowPopupRegistry = { }, floodPredictionMapPopup: { ...windowPopupDefinitions.floodPredictionMapPopup, - component: FloodPredictionMapPopupContent, + component: FloodPredictionPlaceholderPopupContent, }, floodHistoryPopup: { ...windowPopupDefinitions.floodHistoryPopup, @@ -107,12 +110,16 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.gisEditPopup, component: GisEditPopupContent, }, + gisEditSessionPopup: { + ...windowPopupDefinitions.gisEditSessionPopup, + component: GisEditSessionPopupContent, + }, gisManagementPopup: { ...windowPopupDefinitions.gisManagementPopup, component: GisManagementPopupContent, }, flowRiskPopup: { ...windowPopupDefinitions.flowRiskPopup, - component: FlowRiskPopupContent, + component: WaterRiskPopupContent, }, -} as const satisfies Record; +} as const satisfies Record; \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx index e05b3b6b5..a38a8b46b 100644 --- a/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx @@ -4,18 +4,18 @@ type Props = { export default function FeaturePlaceholderPopupContent({ title }: Props) { return ( -
-
-

{title}

+
+
+

{title}

-
-
-

{title}

-

+

+
+

{title}

+

현재는 팝업 창 구조만 우선 연결했습니다.

-

+

상세 기능은 추후 단계에서 구현될 예정입니다.

diff --git a/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx new file mode 100644 index 000000000..d363c7ecf --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx @@ -0,0 +1,5 @@ +import FloodHistoryPopupPage from "@/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage"; + +export default function FloodHistoryPopupContent() { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx new file mode 100644 index 000000000..28a60967c --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import GisEditSessionPanel from "@/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel"; +import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gisEdit/gisEditTypes"; +import { + WINDOW_POPUP_MESSAGE_TYPE, + type WindowPopupContentProps, +} from "../model/types"; +import { windowPopupDefinitions } from "../model/popupDefinitions"; + +const EMPTY_SNAPSHOT: GisEditSessionSnapshot = { + isEditMode: false, + selectedLayerName: "", + activeTool: null, + isSnapEnabled: false, + changes: [], + lastSavedPayload: null, + canUndo: false, + focusedChangeId: null, +}; + +const postMessageToOpener = (type: string, payload?: Record) => { + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage({ type, payload }, window.location.origin); +}; + +export default function GisEditSessionPopupContent(_props: WindowPopupContentProps) { + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC) { + setSnapshot((data.payload ?? EMPTY_SNAPSHOT) as GisEditSessionSnapshot); + } + }; + + window.addEventListener("message", handleMessage); + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + return ( +
+
+

+ {windowPopupDefinitions.gisEditSessionPopup.title} +

+ +
+ +
+ { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE); + }} + onRemoveLastChange={() => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE); + }} + onClearChanges={() => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES); + }} + onFocusChange={(changeId) => { + postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE, { + changeId, + }); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx new file mode 100644 index 000000000..9e2dce7d7 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import GisManagementPopupPage from "@/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage"; + +export default function GisManagementPopupContent() { + return ( + + + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx index f6a739f53..daed9f2d1 100644 --- a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx @@ -8,6 +8,10 @@ export default function InfilChartPopupContent({ searchParams, }: WindowPopupContentProps) { const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + const resultOnly = getPopupParamValue(searchParams.resultOnly) === "Y"; + const aplcnYm = getPopupParamValue(searchParams.aplcnYm); + const analysisInfo = getPopupParamValue(searchParams.analysisInfo); + const inftwtAnlsSn = Number(getPopupParamValue(searchParams.inftwtAnlsSn) || "0"); if (!pplMngNo) { return ( @@ -18,5 +22,13 @@ export default function InfilChartPopupContent({ ); } - return ; -} + return ( + + ); +} \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx new file mode 100644 index 000000000..66bb296e2 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import InftwtResultPopupPage from "@/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage"; + +export default function InftwtResultPopupContent() { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return
; + } + + return ; +} \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx index 8a591e004..148813108 100644 --- a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx @@ -8,6 +8,10 @@ export default function RdiiChartPopupContent({ searchParams, }: WindowPopupContentProps) { const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + const resultOnly = getPopupParamValue(searchParams.resultOnly) === "Y"; + const aplcnYm = getPopupParamValue(searchParams.aplcnYm); + const analysisInfo = getPopupParamValue(searchParams.analysisInfo); + const rainInfwtAnlsSn = Number(getPopupParamValue(searchParams.rainInfwtAnlsSn) || "0"); if (!pplMngNo) { return ( @@ -18,5 +22,13 @@ export default function RdiiChartPopupContent({ ); } - return ; -} + return ( + + ); +} \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx new file mode 100644 index 000000000..c3e6f3039 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import RdiiResultPopupPage from "@/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage"; + +export default function RdiiResultPopupContent() { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return
; + } + + return ; +} \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx index d2913df8e..14af20da4 100644 --- a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx @@ -1,4 +1,7 @@ +"use client"; + import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab"; +import { useEffect, useState } from "react"; import { windowPopupDefinitions } from "../model/popupDefinitions"; import type { WindowPopupContentProps } from "../model/types"; import { getPopupParamValue } from "./popupContentUtils"; @@ -6,9 +9,18 @@ import { getPopupParamValue } from "./popupContentUtils"; export default function ReportTabPopupContent({ searchParams, }: WindowPopupContentProps) { + const [isMounted, setIsMounted] = useState(false); const ctpvNm = getPopupParamValue(searchParams.ctpvNm); const sggNm = getPopupParamValue(searchParams.sggNm); + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return
; + } + return (

diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx index 0bbb50bbe..e85b8fb1d 100644 --- a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx @@ -1,5 +1,44 @@ +"use client"; + import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; +import useStore from "@/store/useStore"; +import { useEffect, useState } from "react"; export default function SearchModalPopupContent() { - return ; + const [isMounted, setIsMounted] = useState(false); + const { + ctpv, + sgg, + BIZ_SE_CD, + BIZ_TY_CD, + fetchCtpv, + fetchSgg, + fetchBIZ_SE_CD, + fetchBIZ_TY_CD, + } = useStore(); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (!isMounted) return; + + // Popup windows do not run map-layout initialization, so load location codes here. + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, isMounted, sgg.length]); + + useEffect(() => { + if (!isMounted) return; + + if (BIZ_SE_CD.length === 0) void fetchBIZ_SE_CD(); + if (BIZ_TY_CD.length === 0) void fetchBIZ_TY_CD(); + }, [BIZ_SE_CD.length, BIZ_TY_CD.length, fetchBIZ_SE_CD, fetchBIZ_TY_CD, isMounted]); + + if (!isMounted) { + return
; + } + + return 0 && sgg.length > 0} />; } diff --git a/apps/sn/src/features/windowPopup/ui/SinkholePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SinkholePopupContent.tsx new file mode 100644 index 000000000..387fc511e --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SinkholePopupContent.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import SinkholePopupPage from "@/app/(map-layout)/map/components/sinkhole/SinkholePopupPage"; + +export default function SinkholePopupContent() { + return ( + + + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx new file mode 100644 index 000000000..eb8cf33ee --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; + +import WaterRiskPopupPage from "@/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage"; +import type { WindowPopupContentProps } from "../model/types"; + +export default function WaterRiskPopupContent(props: WindowPopupContentProps) { + return ( + + + + ); +} diff --git a/apps/sn/src/shared/config/api.ts b/apps/sn/src/shared/config/api.ts index 16d1b5452..eebb15c19 100644 --- a/apps/sn/src/shared/config/api.ts +++ b/apps/sn/src/shared/config/api.ts @@ -1,9 +1,6 @@ import { PROXY_HOST } from "./env"; -// 브라우저에서는 동일 오리진으로 상대경로 호출 → CORS/localhost 고정 문제 방지 -// (Next middleware가 /server/* 요청을 백엔드로 rewrite) -// 서버 환경(SSR 등)에서는 절대 URL이 필요할 수 있어 기존 PROXY_HOST 기반 유지 -export const BASE_URL = `${PROXY_HOST}/server`; +export const BASE_URL = `${PROXY_HOST}`; export const ENDPOINT = { ATTACH_SERVICE: { @@ -17,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", @@ -32,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", @@ -47,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; diff --git a/apps/sn/src/shared/config/auth.ts b/apps/sn/src/shared/config/auth.ts new file mode 100644 index 000000000..7c35ad9f0 --- /dev/null +++ b/apps/sn/src/shared/config/auth.ts @@ -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", +}; diff --git a/apps/sn/src/shared/config/env.ts b/apps/sn/src/shared/config/env.ts index ad982d727..788fdf36f 100644 --- a/apps/sn/src/shared/config/env.ts +++ b/apps/sn/src/shared/config/env.ts @@ -1,8 +1,17 @@ +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; 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 SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN"; +export const SERVER_API_URL = process.env.SERVER_API_URL||'http://dev.onasys.co.kr:8000'; + +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 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.SEWERAGE_API_URL; +export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; + + + + diff --git a/apps/sn/src/shared/config/index.ts b/apps/sn/src/shared/config/index.ts index cd83447d5..04e4f2df5 100644 --- a/apps/sn/src/shared/config/index.ts +++ b/apps/sn/src/shared/config/index.ts @@ -1,2 +1,4 @@ export * from "./api"; export * from "./env"; + +export const DEFAULT_LIST_SIZE = 10; diff --git a/apps/sn/src/shared/lib/bbs-notice-menu.ts b/apps/sn/src/shared/lib/bbs-notice-menu.ts new file mode 100644 index 000000000..a621ed669 --- /dev/null +++ b/apps/sn/src/shared/lib/bbs-notice-menu.ts @@ -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) + ); +} diff --git a/apps/sn/src/shared/lib/cookie.ts b/apps/sn/src/shared/lib/cookie.ts index 8f5d0011b..4bc0fe142 100644 --- a/apps/sn/src/shared/lib/cookie.ts +++ b/apps/sn/src/shared/lib/cookie.ts @@ -1,5 +1,7 @@ + import Cookies from "js-cookie"; + export function getCookie(name: string): string | undefined { if (typeof window === "undefined") { return undefined; diff --git a/apps/sn/src/shared/lib/index.ts b/apps/sn/src/shared/lib/index.ts index 061346946..1d0c4a3a8 100644 --- a/apps/sn/src/shared/lib/index.ts +++ b/apps/sn/src/shared/lib/index.ts @@ -2,6 +2,12 @@ export { useMutationWrapper } from "./use-mutation-wrapper"; export { useAlertStore } from "./use-alert-store"; export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; -export { useDataStore } from "./use-data-store"; export { usePrompt } from "./use-prompt"; export { getCookie, setCookie, removeCookie } from "./cookie"; +export { + buildStatisticsComponentImportPath, + menuMatchesStatisticsTabId, + parseStatisticsMenuUrlPath, + resolveStatisticsCategory, + resolveStatisticsTabId, +} from "./statistics-menu-url"; diff --git a/apps/sn/src/shared/lib/statistics-menu-url.ts b/apps/sn/src/shared/lib/statistics-menu-url.ts new file mode 100644 index 000000000..c20dded4c --- /dev/null +++ b/apps/sn/src/shared/lib/statistics-menu-url.ts @@ -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; +} diff --git a/apps/sn/src/shared/ui/form.tsx b/apps/sn/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/sn/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +
+
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 + 계) 032-590-3723, 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; diff --git a/apps/sn/src/features/auth/login/ui/login.css b/apps/wr/src/auth/login/ui/login.css similarity index 94% rename from apps/sn/src/features/auth/login/ui/login.css rename to apps/wr/src/auth/login/ui/login.css index af73c9c68..e65180ae7 100644 --- a/apps/sn/src/features/auth/login/ui/login.css +++ b/apps/wr/src/auth/login/ui/login.css @@ -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; diff --git a/apps/wr/src/entities/auth/api/auth-service.ts b/apps/wr/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..2ea6bab12 --- /dev/null +++ b/apps/wr/src/entities/auth/api/auth-service.ts @@ -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 { + 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 { + 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 { + tokenManager.clearAuthToken(); + clearDeviceId(); + + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} + +export const authService: AuthService = { + login, + silentRefresh, + declineSessionExtend: clearRefreshTimer, + logout, +}; diff --git a/apps/wr/src/entities/auth/api/login-service.ts b/apps/wr/src/entities/auth/api/login-service.ts deleted file mode 100644 index 92535f7b0..000000000 --- a/apps/wr/src/entities/auth/api/login-service.ts +++ /dev/null @@ -1,89 +0,0 @@ - -import type { LoginService, ILogin } from "../model/login-interface"; -import { tokenManager } from "../lib/token-manager"; -import { 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 { - try { - tokenManager.clearAuthToken(); - const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - userId: payload.userId, - password: payload.password, - }), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data?.message ?? "Login failed"); - } - - 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.setToken("site-id-key", SITE_ID); - tokenManager.setupRefreshTimer(() => this.silentRefresh(),JWT_EXPIRATION_TIME ); - } catch (error) { - - throw new Error(`Login failed: ${error}`); - } - } - - - public async silentRefresh(): Promise { - console.log(' silentRefresh ') - - 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 ?? "Silent refresh failed"); - } - - 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) { - tokenManager.clearAuthToken(); - throw new Error(`Silent refresh failed: ${error}`); - } - } -} - -export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/wr/src/entities/auth/index.ts b/apps/wr/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/wr/src/entities/auth/index.ts +++ b/apps/wr/src/entities/auth/index.ts @@ -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"; diff --git a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx index 796cd44a9..0dc0661ea 100644 --- a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx @@ -1,69 +1,88 @@ "use client"; import { useEffect } from "react"; -import { usePathname } from "next/navigation"; -import { client } from "../../../shared/api"; -import type { IUserInfo } from "../../../shared/store"; -import { useUserStore } from "../../../shared/lib"; -import { ENDPOINT, AUTH_USER_ID } from "../../../shared/config"; -import { isAtLoginRedirectUrl } from "@common/network"; -import { getCookie } from "../../../shared/lib"; +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 pathname = usePathname(); - 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; - } + 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 userId = getCookie(AUTH_USER_ID); - if (!userId) { - throw new Error("Missing required authentication tokens"); - } + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return; - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/info/me`, - ); - if (!userInfo) setUserInfo(response.data); - } + async function authCheck() { + const userId = getToken(AUTH_USER_ID); + if (!userId) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + return; + } - useEffect(() => { - (async () => { - try { - await authCheck(); - } catch (error) { - console.error(error); - setUserInfo(null); + if (userInfo) return; - if (isAtLoginRedirectUrl(LOGIN_PAGE_URL)) return; + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + setUserInfo(response.data); + } - // const hostname = window.location.hostname; + authCheck().catch((error) => { + // 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨 + if (isAxiosError(error) && error.response?.status === 401) return; - // // 개발 환경에서는 localhost 도메인 사용 - // if ( - // hostname === "localhost" || - // hostname === "127.0.0.1" || - // hostname === "con.mind-one.co.kr" - // ) - // router.push("/auth/login"); + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + }); + }, []); - // // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - // // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용 - // if (hostname.includes("hasuon.or.kr")) - // window.location.assign(LOGIN_PAGE_URL); // 세션 만료 시 CM 로그인 페이지로 리다이렉트 + // silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동 + useEffect(() => { + function handleAuthLogout() { + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + } - // 개발/운영 환경에서 로그인 URL 분기는 환경 변수로 처리 (.env.*) - window.location.assign(LOGIN_PAGE_URL); - } - })(); - }, [pathname]); + window.addEventListener("auth:logout", handleAuthLogout); + return () => + window.removeEventListener("auth:logout", handleAuthLogout); + }, []); - return <>{children}; + return <>{children}; }; export { AuthWrapper }; diff --git a/apps/wr/src/entities/auth/lib/device-id.ts b/apps/wr/src/entities/auth/lib/device-id.ts new file mode 100644 index 000000000..f844fcbef --- /dev/null +++ b/apps/wr/src/entities/auth/lib/device-id.ts @@ -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); +} diff --git a/apps/wr/src/entities/auth/lib/token-manager.ts b/apps/wr/src/entities/auth/lib/token-manager.ts index 919386622..172f2cc3a 100644 --- a/apps/wr/src/entities/auth/lib/token-manager.ts +++ b/apps/wr/src/entities/auth/lib/token-manager.ts @@ -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; // 토큰 subject - 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] ?? ""; // 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; - } - - 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: window.location.protocol === "https:", - sameSite: "lax", - }); - } - - public removeToken(key: string): void { - removeCookie(key); - } - - public setupRefreshTimer( - callback: () => Promise, - 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; diff --git a/apps/wr/src/entities/menu-authority/index.ts b/apps/wr/src/entities/menu-authority/index.ts index 2a555b77e..d5991d01c 100644 --- a/apps/wr/src/entities/menu-authority/index.ts +++ b/apps/wr/src/entities/menu-authority/index.ts @@ -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"; diff --git a/apps/wr/src/entities/menu/index.ts b/apps/wr/src/entities/menu/index.ts index 04f63e38b..2bd1b1787 100644 --- a/apps/wr/src/entities/menu/index.ts +++ b/apps/wr/src/entities/menu/index.ts @@ -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"; diff --git a/apps/wr/src/features/auth/login/ui/login-page.tsx b/apps/wr/src/features/auth/login/ui/login-page.tsx deleted file mode 100644 index 4c340f78e..000000000 --- a/apps/wr/src/features/auth/login/ui/login-page.tsx +++ /dev/null @@ -1,138 +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) => { - 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 = "/map/mainMap"; - }; - - return ( -
-
-
-

- 하수도정보시스템 -
- CMS 통합관리시스템 -

-

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

-
-
- logo -
-
-
-
-
    -
  • -

    ID

    -
    - - -
    -
  • -
  • -

    PASSWORD

    -
    - - -
    -
  • -
-
-
- - -
- {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} -
- -
-
-

copyright © 2023 KECO. All rights reserved.

-
- -

- - 주소 : (22689) 인천광역시 서구 환경로 42(경서동 - 종합환경연구단지) - - - 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, - 3722 - - 팩스 :032-590-3729 -

-
-
- -
- ); -} - -export { LoginPage }; diff --git a/apps/wr/src/shared/config/auth.ts b/apps/wr/src/shared/config/auth.ts new file mode 100644 index 000000000..7c35ad9f0 --- /dev/null +++ b/apps/wr/src/shared/config/auth.ts @@ -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", +}; diff --git a/apps/wr/src/shared/config/env.ts b/apps/wr/src/shared/config/env.ts index 89dd8addd..641d870a3 100644 --- a/apps/wr/src/shared/config/env.ts +++ b/apps/wr/src/shared/config/env.ts @@ -1,15 +1,20 @@ export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "WR"; export const PORT = process.env.NEXT_PUBLIC_PORT || "3001"; 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://apigateway.iipbs-dev.svc.cluster.local:8000/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_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://apigateway.iipbs-dev.svc.cluster.local:8000/api/v1'; +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_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; + diff --git a/apps/wr/src/shared/lib/bbs-notice-menu.ts b/apps/wr/src/shared/lib/bbs-notice-menu.ts new file mode 100644 index 000000000..a621ed669 --- /dev/null +++ b/apps/wr/src/shared/lib/bbs-notice-menu.ts @@ -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) + ); +} diff --git a/apps/wr/src/shared/lib/index.ts b/apps/wr/src/shared/lib/index.ts index b8b4c52c0..1d0c4a3a8 100644 --- a/apps/wr/src/shared/lib/index.ts +++ b/apps/wr/src/shared/lib/index.ts @@ -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"; diff --git a/apps/wr/src/shared/lib/statistics-menu-url.ts b/apps/wr/src/shared/lib/statistics-menu-url.ts new file mode 100644 index 000000000..c20dded4c --- /dev/null +++ b/apps/wr/src/shared/lib/statistics-menu-url.ts @@ -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; +} diff --git a/apps/wr/src/store/tab-store.ts b/apps/wr/src/store/tab-store.ts new file mode 100644 index 000000000..0379b3601 --- /dev/null +++ b/apps/wr/src/store/tab-store.ts @@ -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 { + try { + const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd); + apply(response?.[0] ?? null); + } catch { + apply(null); + } +} + +function scheduleTabPageMenuAuthorityFetch( + get: () => TabStore, + set: ( + partial: + | Partial + | ((state: TabStore) => Partial), + ) => 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 + | ((state: TabStore) => Partial), +) => 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()( + 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 + > | 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(); +} diff --git a/docs/session-extend-confirm-guide.md b/docs/session-extend-confirm-guide.md index 0668f1f3d..23a1ce099 100644 --- a/docs/session-extend-confirm-guide.md +++ b/docs/session-extend-confirm-guide.md @@ -47,7 +47,7 @@ sequenceDiagram else 취소 Header->>API: declineSessionExtend() API->>RefreshTimer: clearRefreshTimer() - Note over RefreshTimer: 만료까지 자동 갱신 없음 + Note over RefreshTimer: 취소시 만료까지 자동 갱신 없음 end ``` diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 2eea996df..04bf3a504 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -49,7 +49,9 @@ function Gis({ searchView: SearchView, detailView: DetailView, onFeatureSelect, + onMapBlankClick, canSelectFeature, + sinkholeLegendIconVisible = false, setIsSearchModalOpen, onCheckedLayersChange, searchBar: SearchBar, @@ -311,6 +313,7 @@ function Gis({ }} imageVisible={imgVisble} layerIconVisible={layerIconVisible} + sinkholeLegendIconVisible={sinkholeLegendIconVisible} isPlaying={isPlaying} setIsPlaying={setIsPlaying} targetLayerTypes={targetLayerTypes} @@ -359,7 +362,11 @@ function Gis({ )} - +

diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index ac862c2f1..1ed788398 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -3,15 +3,21 @@ import { GisContext } from "./Gis"; import GisApp from "./lib/gis.core"; import GisOlSelect from "./lib/gis.select"; -export default React.memo(function GisSelect({ onFeatureSelect, canSelectFeature }) { +export default React.memo(function GisSelect({ onFeatureSelect, onMapBlankClick, canSelectFeature }) { const { gisMap } = useContext(GisContext); const onFeatureSelectRef = useRef(onFeatureSelect); + // 빈 지도 클릭 콜백도 interaction 재생성 없이 최신 화면 상태를 참조하도록 ref로 보관한다. + const onMapBlankClickRef = useRef(onMapBlankClick); const canSelectFeatureRef = useRef(canSelectFeature); useEffect(() => { onFeatureSelectRef.current = onFeatureSelect; }, [onFeatureSelect]); + useEffect(() => { + onMapBlankClickRef.current = onMapBlankClick; + }, [onMapBlankClick]); + useEffect(() => { canSelectFeatureRef.current = canSelectFeature; }, [canSelectFeature]); @@ -31,6 +37,8 @@ export default React.memo(function GisSelect({ onFeatureSelect, canSelectFeature return; } onFeatureSelectRef.current?.(features[0]); + } else { + onMapBlankClickRef.current?.(); } }); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index cdf67af25..de04cb91b 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -46,6 +46,7 @@ export default React.memo(function GisTree({ onProgressChange = () => { }, layerSettingView: LayerSettingView = null, layerIconVisible, + sinkholeLegendIconVisible = false, inlineLayerSettingView, treeSectionClassName = "", toggleButtonPosition = "center", @@ -526,7 +527,7 @@ export default React.memo(function GisTree({ <> {/* 사이드 패널 */}
{showLayerPanelTitle && ( <> -

시설물

-
+

시설물

+
)} {/* DnD Tree */} @@ -561,7 +562,8 @@ export default React.memo(function GisTree({ nodeLabel === "지반침하정보"; const isFloodHistoryRootTreeNode = nodeLabel === "침수흔적도"; const hideTreeLegendIcon = - isSinkholeRootTreeNode || isFloodHistoryRootTreeNode; + (isSinkholeRootTreeNode && !sinkholeLegendIconVisible) || + isFloodHistoryRootTreeNode; const showLayerActionButton = layerIconVisible?.(node); const showLayerActionIcon = depth > 0 && imageVisible; @@ -570,7 +572,7 @@ export default React.memo(function GisTree({ return (
handleLayerImgClick( node.data.lyrTyNm, @@ -652,14 +654,14 @@ export default React.memo(function GisTree({
{LayerSettingView && ( -
+
)} {!detachedToggleButton && ( @@ -668,7 +670,7 @@ export default React.memo(function GisTree({ {detachedToggleButton && visible && (
); }; + diff --git a/packages/common/business_components/src/ui/select/lib/selectBox.tsx b/packages/common/business_components/src/ui/select/lib/selectBox.tsx index cc4bac4d1..da0761733 100644 --- a/packages/common/business_components/src/ui/select/lib/selectBox.tsx +++ b/packages/common/business_components/src/ui/select/lib/selectBox.tsx @@ -1,4 +1,4 @@ -import * as SelectPrimitive from "@radix-ui/react-select" +import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown, ChevronUp } from "lucide-react" import * as React from "react" @@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-14 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xl ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...props} @@ -148,3 +148,5 @@ export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } + + diff --git a/packages/common/business_components/src/ui/select/select.css b/packages/common/business_components/src/ui/select/select.css index 8aa16f386..721ab81b3 100644 --- a/packages/common/business_components/src/ui/select/select.css +++ b/packages/common/business_components/src/ui/select/select.css @@ -1,8 +1,8 @@ -@tailwind components; +@tailwind components; @layer components { .selectbox_root { - @apply flex items-center; + @apply flex items-center overflow-visible py-1; } .selectbox_label { diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index 351f08dff..e07d1e190 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { Checkbox, @@ -37,6 +37,7 @@ type UiTableProps = { headButton?: React.ReactNode | undefined; widthFixed?: boolean; isHideCount?: boolean; // 총 건수 default false + clickableHeadIds?: string[]; }; type UiHeadProps = { @@ -69,7 +70,8 @@ export function UiTable>({ curPage = 1, headButton = undefined, widthFixed = false, - isHideCount = false, // 총 건수 + isHideCount = false, + clickableHeadIds, }: UiTableProps) { const [cur, setCur] = useState(curPage); const [idx, setIdx] = useState(index); @@ -97,32 +99,36 @@ export function UiTable>({ useEffect(() => { setIdx(index); - }, [index]) + }, [index]); useEffect(() => { setCur(curPage); - }, [curPage]) + }, [curPage]); const handlePageChange = (page: number) => { setCur(page); onPageChange(page); - if (index == 0) - setIdx(0) + if (index == 0) setIdx(0); + }; + + const isHeadClickable = (headId: string) => { + if (!cellClick) return false; + if (!clickableHeadIds || clickableHeadIds.length === 0) return true; + return clickableHeadIds.includes(headId); }; const handleCellClick = (index: number, headId: string) => { - if (cellClick) { - cellClick(index, headId); - setIdx(index); - } + if (!isHeadClickable(headId) || !cellClick) return; + + cellClick(index, headId); + setIdx(index); }; const getLeafCols = (list: UiHeadProps[]) => { const maxRow = list?.reduce((max, head) => Math.max(max, head.row || 0), 0); return list - .filter(h => h.visible !== false) - .filter(h => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow) - ; + .filter((h) => h.visible !== false) + .filter((h) => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow); }; const leafCols = getLeafCols(headlist); @@ -153,12 +159,15 @@ export function UiTable>({ )}
- {widthFixed ? + {widthFixed ? ( - {leafCols.map(col => ( + {leafCols.map((col) => ( ))} - : ""} + + ) : ( + "" + )} {children ? children : } @@ -174,9 +183,9 @@ export function UiTable>({ head.id.length > 1 && (boldArray.includes(index) ? ( >({ ? String(cellValue ?? "") : String(cellValue).includes(".") ? String(cellValue) - .split(".") - .map((part, index) => - index === 0 - ? part.replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ) - : part, - ) - .join(".") - : String(cellValue ?? "").replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ); + .split(".") + .map((part, index) => + index === 0 + ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + : part, + ) + .join(".") + : String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ","); })()} ) : ( handleCellClick(index, head.id)} key={`cell-${head.id}-${index}`} > - { - //체크박스 유형애서 readOnly가 필요한 경우를 위해서 ChkClass 를 사용해서 readOnly 처리함. - (() => { - const cellValue = (el as Record)[head.id]; - return typeof cellValue === "object" && - cellValue !== null && - "chk" in (cellValue as object) && - "readonly" in (cellValue as object) ? ( + {(() => { + const cellValue = (el as Record)[head.id]; + return typeof cellValue === "object" && + cellValue !== null && + "chk" in (cellValue as object) && + "readonly" in (cellValue as object) ? ( { - { - console.log(cellValue); - } - if ( - (cellValue as ChkClass).readonly == true - ) - return; - (cellValue as ChkClass).chk = - Boolean(checked); + if ((cellValue as ChkClass).readonly == true) return; + (cellValue as ChkClass).chk = Boolean(checked); }} > @@ -258,22 +250,13 @@ export function UiTable>({ String(cellValue) .split(".") .map((part, index) => - index === 0 - ? part.replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ) - : part, + index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part, ) .join(".") ) : ( - String(cellValue ?? "").replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ) + String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ",") ); - })() - } + })()} )), )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d59b101f1..fd99062b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,7 +94,7 @@ importers: version: 1.11.20 next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-hook-form: specifier: ^7.54.2 version: 7.72.0(react@19.2.4) @@ -104,10 +104,10 @@ importers: devDependencies: eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -188,7 +188,7 @@ importers: version: 0.474.0(react@19.2.4) next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-hook-form: specifier: ^7.54.2 version: 7.72.0(react@19.2.4) @@ -210,13 +210,13 @@ importers: version: 3.0.6 eslint: specifier: ^9.0.0 - version: 9.39.4(jiti@1.21.7) + version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 15.3.8 - version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-react-hooks: specifier: ^5.0.0 - version: 5.2.0(eslint@9.39.4(jiti@1.21.7)) + version: 5.2.0(eslint@9.39.4(jiti@2.6.1)) postcss: specifier: ^8.4.49 version: 8.5.8 @@ -241,6 +241,9 @@ importers: '@common/assets': specifier: workspace:* version: link:../../packages/common/assets + '@common/auth': + specifier: workspace:* + version: link:../../packages/common/auth '@common/components': specifier: workspace:* version: link:../../packages/common/components @@ -324,7 +327,7 @@ importers: version: 0.474.0(react@19.2.4) next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-qrcode: specifier: ^2.5.1 version: 2.5.1(react@19.2.4) @@ -349,6 +352,9 @@ importers: tus-js-client: specifier: ^4.3.1 version: 4.3.1 + uuid: + specifier: ^14.0.1 + version: 14.0.1 zod: specifier: ^3.24.2 version: 3.25.76 @@ -454,7 +460,7 @@ importers: version: 3.0.5 next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -537,6 +543,9 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config + '@common/network': + specifier: workspace:* + version: link:../../packages/common/network '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.10 @@ -605,7 +614,7 @@ importers: version: 0.474.0(react@19.2.4) next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-qrcode: specifier: ^2.5.1 version: 2.5.1(react@19.2.4) @@ -630,6 +639,9 @@ importers: tus-js-client: specifier: ^4.3.1 version: 4.3.1 + uuid: + specifier: ^14.0.1 + version: 14.0.1 zod: specifier: ^3.24.2 version: 3.25.76 @@ -723,7 +735,7 @@ importers: version: 0.474.0(react@19.2.4) next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -823,7 +835,7 @@ importers: version: 0.474.0(react@19.2.4) next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-hook-form: specifier: ^7.54.2 version: 7.72.0(react@19.2.4) @@ -843,6 +855,9 @@ importers: '@eslint/eslintrc': specifier: ^3 version: 3.3.5 + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@types/webpack': specifier: ^5.28.5 version: 5.28.5 @@ -867,12 +882,21 @@ importers: '@common/assets': specifier: workspace:* version: link:../../packages/common/assets + '@common/auth': + specifier: workspace:* + version: link:../../packages/common/auth '@common/business_components': specifier: workspace:* version: link:../../packages/common/business_components '@common/components': specifier: workspace:* version: link:../../packages/common/components + '@common/config': + specifier: workspace:* + version: link:../../packages/common/config + '@common/network': + specifier: workspace:* + version: link:../../packages/common/network '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.10 @@ -908,7 +932,7 @@ importers: version: 3.0.5 next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ol: specifier: ^7.3.0 version: 7.5.2 @@ -927,6 +951,9 @@ importers: select: specifier: ^1.1.2 version: 1.1.2 + uuid: + specifier: ^14.0.1 + version: 14.0.1 webpack: specifier: ^5.98.0 version: 5.105.4 @@ -961,6 +988,9 @@ importers: '@common/assets': specifier: workspace:* version: link:../../packages/common/assets + '@common/auth': + specifier: workspace:* + version: link:../../packages/common/auth '@common/business_components': specifier: workspace:* version: link:../../packages/common/business_components @@ -970,6 +1000,9 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config + '@common/network': + specifier: workspace:* + version: link:../../packages/common/network '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.10 @@ -1014,7 +1047,7 @@ importers: version: 2.30.1 next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ol: specifier: ^7.3.0 version: 7.5.2 @@ -1042,6 +1075,9 @@ importers: tus-js-client: specifier: ^4.3.1 version: 4.3.1 + uuid: + specifier: ^14.0.1 + version: 14.0.1 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -1204,7 +1240,7 @@ importers: version: 0.474.0(react@19.2.4) next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-qrcode: specifier: ^2.5.1 version: 2.5.1(react@19.2.4) @@ -1241,6 +1277,9 @@ importers: tus-js-client: specifier: ^4.3.1 version: 4.3.1 + uuid: + specifier: ^14.0.1 + version: 14.0.1 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -1296,12 +1335,21 @@ importers: '@common/assets': specifier: workspace:* version: link:../../packages/common/assets + '@common/auth': + specifier: workspace:* + version: link:../../packages/common/auth '@common/business_components': specifier: workspace:* version: link:../../packages/common/business_components '@common/components': specifier: workspace:* version: link:../../packages/common/components + '@common/config': + specifier: workspace:* + version: link:../../packages/common/config + '@common/network': + specifier: workspace:* + version: link:../../packages/common/network '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.10 @@ -1344,6 +1392,9 @@ importers: js-cookie: specifier: ^3.0.5 version: 3.0.5 + js-sha512: + specifier: ^0.9.0 + version: 0.9.0 lucide-react: specifier: ^0.474.0 version: 0.474.0(react@19.2.4) @@ -1352,7 +1403,7 @@ importers: version: 2.30.1 next: specifier: 15.5.18 - version: 15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ol: specifier: ^7.3.0 version: 7.5.2 @@ -1386,6 +1437,9 @@ importers: tus-js-client: specifier: ^4.3.1 version: 4.3.1 + uuid: + specifier: ^14.0.1 + version: 14.0.1 xlsx: specifier: ^0.18.5 version: 0.18.5 @@ -2942,6 +2996,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -5362,6 +5421,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6315,6 +6379,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -7181,6 +7255,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -8452,6 +8527,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -11404,6 +11483,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12093,7 +12175,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@15.5.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@15.5.18(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -12111,6 +12193,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.18 '@next/swc-win32-arm64-msvc': 15.5.18 '@next/swc-win32-x64-msvc': 15.5.18 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -12294,6 +12377,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} pngjs@5.0.0: {}