Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy
Deploy Frontend / deploy (push) Successful in 10m10s

This commit is contained in:
2026-07-20 13:52:10 +09:00
174 changed files with 23674 additions and 1812 deletions
+1 -12
View File
@@ -68,7 +68,7 @@ async function handler(
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "strict",
domain: getCookieDomain(),
domain: COOKIE_DOMAIN,
path: "/",
});
}
@@ -92,14 +92,3 @@ export async function PUT(request: Request, props: { params: Promise<{ url: stri
const params = await props.params;
return handler(request, params, "PUT");
}
function getCookieDomain(): string | undefined {
if (typeof window === "undefined") return undefined;
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
const { hostname } = window.location;
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
return hostname;
}
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
transpilePackages: ["@common/assets"],
};
+2
View File
@@ -2,6 +2,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
@@ -135,6 +136,7 @@ async function handler(
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
domain: COOKIE_DOMAIN,
path: "/",
});
}
+1 -12
View File
@@ -70,7 +70,7 @@ async function handler(
response.cookies.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "strict",
domain: getCookieDomain(),
domain: COOKIE_DOMAIN,
path: "/",
});
}
@@ -105,14 +105,3 @@ 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;
}
+4 -4
View File
@@ -41,7 +41,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl));
return addSecurityHeaders(response);
@@ -58,7 +58,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -83,7 +83,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Link API Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -97,7 +97,7 @@ export function middleware(req: NextRequest) {
}
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.next();
return addSecurityHeaders(response);
+1 -1
View File
@@ -18,7 +18,7 @@ const nextConfig: NextConfig = {
fetches: {
fullUrl: true,
},
},
},
async headers() {
return [
{
+7 -7
View File
@@ -6,18 +6,18 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://localhost:3015
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
# NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
SERVER_API_URL = http://dev.onasys.co.kr:8000
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # local
SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
# GATEWAY_URL=http://localhost:8883 # local
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
+8 -11
View File
@@ -6,20 +6,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
#NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
#NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
SERVER_API_URL = http://dev.onasys.co.kr:8000
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
CONTENTS_INTRO = 31
CONTENTS_EXAMPLE = 32
+6 -6
View File
@@ -7,17 +7,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_PROXY_HOST = https://devreuse.hasuon.or.kr
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
CONTENTS_INTRO = 24
CONTENTS_EXAMPLE = 25
+6 -6
View File
@@ -1,14 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { CLAIM_NAME, AUTH_USER_ID } from "./src/shared/config";
// import { SERVER_API_FO_URL, SERVER_API_WR_URL } from "./src/shared/config/env";
import { SERVER_API_FO_URL, SERVER_API_WR_URL } from "./src/shared/config/env";
// const CLAIM_NAME = "Authorization";
// const AUTH_USER_ID = "token-id";
const serverApiUrl = process.env.NEXT_PUBLIC_CS_SERVER_API_URL;
const serverApiUrl = process.env.CS_SERVER_API_URL;
const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || "";
const LX_GEOSERVER_URL = process.env.NEXT_PUBLIC_LX_GEOSERVER_URL || "";
const GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL || "";
// const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
const GATEWAY_URL = process.env.GATEWAY_URL || "";
// const SEWERAGE_API_URL = process.env.SEWERAGE_API_URL || "";
const BUSINESS_JUSO_API_URL = process.env.NEXT_PUBLIC_FINDADRESS_URL || "";
const AdressKey = process.env.NEXT_PRIVATE_WR_ADDRESS_KEY || "";
@@ -117,12 +117,12 @@ export function middleware(req: NextRequest) {
if (pathname.startsWith("/reuse-service")) {
const queryParams = searchParams.toString();
const newUrl = `${GATEWAY_URL}${pathname}${queryParams ? `?${queryParams}` : ""}`;
const newUrl = `${SERVER_API_WR_URL}${pathname.replace("/reuse-service", "")}${queryParams ? `?${queryParams}` : ""}`;
console.log(`[Middleware] Rewriting from '/reuse-service' path to: ${newUrl}`);
return NextResponse.rewrite(new URL(newUrl));
} else if (pathname.startsWith("/operation-service")) {
const queryParams = searchParams.toString();
const newUrl = `${GATEWAY_URL}${pathname}${queryParams ? `?${queryParams}` : ""}`;
const newUrl = `${SERVER_API_FO_URL}${pathname.replace("/operation-service", "")}${queryParams ? `?${queryParams}` : ""}`;
console.log(`[Middleware] Rewriting from '/operation-service' path to: ${newUrl}`);
return NextResponse.rewrite(new URL(newUrl));
} else if (pathname.startsWith("/sewerage-service")) {
+4 -5
View File
@@ -1,13 +1,12 @@
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "DM";
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_DM_URL = process.env.NEXT_PUBLIC_DM_SERVER_API_URL ||'http://con.mind-one.co.kr:11035/api/v1';
export const SERVER_API_FO_URL = process.env.NEXT_PUBLIC_FO_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
export const SERVER_API_WR_URL = process.env.NEXT_PUBLIC_WR_SERVER_API_URL||' http://con.mind-one.co.kr:11080/api/v1';
export const SERVER_API_CS_URL = process.env.NEXT_PUBLIC_CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
export const SERVER_API_FO_URL = process.env.FO_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
export const SERVER_API_WR_URL = process.env.WR_SERVER_API_URL||' http://con.mind-one.co.kr:11080/api/v1';
export const SERVER_API_CS_URL = process.env.CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
export const SERVER_API_CS_TUS_URL = process.env.NEXT_PUBLIC_CS_TUS_UPLOAD ||'/attach-service/api/v1/tus/upload'
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
export const SERVER_SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL;
export const SERVER_SEWERAGE_API_URL = process.env.SEWERAGE_API_URL;
+6 -5
View File
@@ -7,13 +7,14 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
+6 -5
View File
@@ -7,13 +7,14 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11300
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
DC_URL = https://apigateway.apps.rsonesoft.com/dc-service
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
+8 -6
View File
@@ -7,14 +7,16 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_PROXY_HOST = https://devlarva.hasuon.or.kr
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-service
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/dc-service
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_COOKIE_DOMAIN=.hasuon.or.kr
+18
View File
@@ -2,6 +2,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
@@ -19,6 +20,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");
@@ -70,6 +76,7 @@ async function handler(
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
domain: getCookieDomain(),
path: "/",
});
}
@@ -104,3 +111,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;
}
+2 -2
View File
@@ -5,9 +5,9 @@ const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || "";
const LX_GEOSERVER_URL = process.env.NEXT_PUBLIC_LX_GEOSERVER_URL || "";
const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
const SEWERAGE_API_URL = process.env.SEWERAGE_API_URL || "";
const serverApiUrl = SERVER_API_CS_URL;
const DC_URL = process.env.NEXT_PUBLIC_DC_URL || "";
const DC_URL = process.env.DC_URL || "";
/** 공통: URLSearchParams의 값을 decodeURIComponent 적용하여 querystring 생성 */
function toDecodedQueryString(params: URLSearchParams): string {
+2 -1
View File
@@ -35,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",
+12 -6
View File
@@ -10,10 +10,9 @@ import {
type AuthService,
getRemainingMs,
} from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
removeToken,
setToken,
setupRefreshTimer,
tokenManager,
@@ -24,7 +23,10 @@ import { clearTabStorePersist } from "@/src/store/tab-store";
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-Device-Id": getOrCreateDeviceId(),
},
body: JSON.stringify(payload),
});
@@ -56,7 +58,12 @@ async function silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
{
method: "PUT",
headers: {
"X-Device-Id": getOrCreateDeviceId(),
},
},
);
const data = await response.json();
@@ -93,8 +100,7 @@ async function silentRefresh(): Promise<void> {
export async function logout(): Promise<void> {
tokenManager.clearAuthToken();
removeToken("site-id-key");
removeToken("site-theme-key");
clearDeviceId();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
@@ -0,0 +1,20 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
+5 -6
View File
@@ -2,17 +2,16 @@ 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 ||
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.NEXT_PUBLIC_FO_SERVER_API_URL ||
process.env.NEXT_PUBLIC_SERVER_API_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.NEXT_PUBLIC_CS_SERVER_API_URL ||
process.env.CS_SERVER_API_URL ||
"http://dev.onasys.co.kr:8000";
export const SERVER_API_URL =
process.env.NEXT_PUBLIC_CS_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 ||
@@ -23,7 +22,7 @@ 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;
+4 -4
View File
@@ -11,9 +11,9 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
SERVER_API_URL = http://dev.onasys.co.kr:8000
SEWERAGE_API_URL = http://localhost:8883
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
+3 -3
View File
@@ -6,11 +6,11 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
+4 -3
View File
@@ -8,10 +8,11 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_PROXY_HOST = https://devlion.hasuon.or.kr
#NEXT_PUBLIC_SUB_DOMAIN = http://front-sn-feature.iipbs-web.svc.cluster.local:13000
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_COOKIE_DOMAIN=.hasuon.or.kr
@@ -1,18 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import type { AgedPipeFilters } from "./agedPipeUtils";
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
type MaybePromise<T = void> = T | Promise<T>;
type Props = {
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
onApply: (filters?: AgedPipeFilters) => void;
onApply: (filters?: AgedPipeFilters) => MaybePromise;
compact?: boolean;
};
export default function AgedPipeLayerSettingPanel({
filters,
onFiltersChange,
onApply,
compact = false,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(false);
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
@@ -21,17 +25,74 @@ export default function AgedPipeLayerSettingPanel({
setElapsedYearsText(String(filters.elapsedYears ?? ""));
}, [filters.elapsedYears]);
const commitElapsedYears = () => {
const commitElapsedYears = async () => {
const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
if (!validateElapsedYears(elapsedYears)) {
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
return;
}
const nextFilters = {
...filters,
elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText),
elapsedYears,
};
onFiltersChange(nextFilters);
onApply(nextFilters);
await onApply(nextFilters);
alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`);
};
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
const numberInputClass = inputClass + " w-[40px]";
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
const settingRow = (
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-slate-700">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<input
id="aged-pipe-elapsed-years"
className={numberInputClass}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={elapsedYearsText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setElapsedYearsText(digits);
}}
onKeyDown={(event) => {
if (event.key === "Enter") void commitElapsedYears();
}}
/>
<button
type="button"
className={applyButtonClass}
onClick={() => void commitElapsedYears()}
title="적용"
aria-label="경과년도 적용"
>
✓
</button>
</div>
</div>
);
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
{settingRow}
</div>
);
}
return (
<div className="overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
<button
type="button"
@@ -39,41 +100,14 @@ export default function AgedPipeLayerSettingPanel({
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "›" : "⌄"}
{isCollapsed ? "+" : "-"}
</button>
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
</div>
{!isCollapsed && (
<div className="px-2 py-2">
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
<input
id="aged-pipe-elapsed-years"
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={elapsedYearsText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setElapsedYearsText(digits);
}}
onKeyDown={(event) => {
if (event.key === "Enter") commitElapsedYears();
}}
/>
<button
type="button"
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
onClick={commitElapsedYears}
title="적용"
>
✓
</button>
</div>
{settingRow}
</div>
)}
</div>
@@ -196,17 +196,25 @@ export default function AgedPipePopupPage() {
window.close();
};
const getCellClassName = (_row: AgedPipeRow, headId: string) => {
if (headId === "mqltCd") {
return "break-all whitespace-normal";
}
return "whitespace-normal";
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">노후관로 현황</h1>
<header className="sn-popup-header">
<h1 className="sn-popup-title">노후관로 현황</h1>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
className="sn-popup-header-button"
onClick={handleMinimize}
>
최소화
@@ -215,17 +223,17 @@ export default function AgedPipePopupPage() {
</header>
{downloadProgressText ? (
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
<div className="sn-popup-success-bar">
{downloadProgressText}
</div>
) : null}
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[160px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년도</div>
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">경과년도</div>
<Input
className="h-10 !text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
@@ -244,10 +252,10 @@ export default function AgedPipePopupPage() {
/>
</label>
<label className="block md:max-w-[320px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
@@ -266,10 +274,10 @@ export default function AgedPipePopupPage() {
</select>
</label>
<label className="block md:max-w-[320px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
@@ -282,12 +290,12 @@ export default function AgedPipePopupPage() {
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
@@ -295,8 +303,8 @@ export default function AgedPipePopupPage() {
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
@@ -304,19 +312,65 @@ export default function AgedPipePopupPage() {
</div>
</div>
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
<div className="aged-pipe-popup-table min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
cellClassName={getCellClassName}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
className="!m-0 overflow-x-hidden"
/>
</div>
</section>
<style jsx global>{`
.aged-pipe-popup-table .card-header {
margin-bottom: 0.6rem;
}
.aged-pipe-popup-table .card-header h3 {
font-size: 1.7rem;
line-height: 1.25;
}
.aged-pipe-popup-table .card {
overflow-x: hidden;
}
.aged-pipe-popup-table .table-ui {
width: 100%;
max-width: 100%;
table-layout: fixed;
}
.aged-pipe-popup-table .table-ui th {
padding: 0.8rem 0.45rem;
font-size: 1.1rem;
line-height: 1.2;
word-break: keep-all;
}
.aged-pipe-popup-table .table-ui td {
padding: 0.85rem 0.4rem;
font-size: 1.1rem;
line-height: 1.25;
overflow-wrap: anywhere;
word-break: break-word;
}
.aged-pipe-popup-table .table-ui td:nth-child(2),
.aged-pipe-popup-table .table-ui td:nth-child(3),
.aged-pipe-popup-table .table-ui td:nth-child(8) {
word-break: keep-all;
}
`}</style>
</main>
);
}
@@ -59,9 +59,9 @@ export const AGED_PIPE_LIST_HEADLIST = [
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
{ id: "usgCd", title: "용도", widthPercent: 14 },
{ id: "usgCd", title: "용도", widthPercent: 12 },
{ id: "mqltCd", title: "재질", widthPercent: 18 },
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
];
export const AGED_PIPE_EXCEL_FIELDS = [
@@ -1,11 +1,10 @@
"use client";
import { useState } from "react";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import {
ChartColumnBig,
ChevronLeft,
ChevronRight,
History,
PanelsTopLeft,
PencilRuler,
Search,
@@ -16,36 +15,95 @@ import {
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => void;
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void;
onToggleGisEdit?: () => void;
isGisEditActive?: boolean;
isInfoSearchActive?: boolean;
initialActiveKey?: WindowPopupKey | null;
};
type MenuIconProps = {
isActive: boolean;
};
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
<svg
viewBox="0 0 24 24"
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-primary" : "text-label"}`}
aria-hidden="true"
fill="none"
>
<path
d="M12 2.8C9.5 6.5 6.7 9.8 6.7 13.3A5.3 5.3 0 0 0 12 18.6a5.3 5.3 0 0 0 5.3-5.3c0-3.5-2.8-6.8-5.3-10.5Z"
fill="currentColor"
fillOpacity="0.14"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path
d="M9.5 13.4c0-1.2.8-2.6 1.9-4.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
opacity="0.9"
/>
</svg>
);
const ANALYSIS_POPUP_ITEMS: Array<{
key: WindowPopupKey | "infoSearch";
label: string;
icon: typeof ChartColumnBig;
icon?: ComponentType<{ className?: string }>;
renderIcon?: (props: MenuIconProps) => ReactNode;
}> = [
{ key: "infoSearch", label: "정보검색", icon: Search },
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
{ key: "floodHistoryPopup", label: "침수발생이력", icon: History },
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
{ key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon },
{ key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig },
];
export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) {
export default function AnalysisPopupMenu({
onOpenPopup,
onOpenInfoSearch,
onToggleGisEdit,
isGisEditActive = false,
isInfoSearchActive = false,
initialActiveKey = null,
}: Props) {
const [isOpen, setIsOpen] = useState(true);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(initialActiveKey);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
setActiveKey(null);
}
}, [activeKey, isGisEditActive]);
useEffect(() => {
if (!isInfoSearchActive && activeKey === "infoSearch") {
setActiveKey(null);
}
}, [activeKey, isInfoSearchActive]);
useEffect(() => {
if (!initialActiveKey) return;
setActiveKey((currentKey) => currentKey ?? initialActiveKey);
}, [initialActiveKey]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
{isOpen ? (
<div className="absolute right-[28px] w-[132px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]">
<div className="absolute right-[28px] w-[154px] overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-border bg-secondary px-[10px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" />
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
</div>
@@ -53,7 +111,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeKey === item.key;
const isActive = item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
return (
<button
@@ -61,10 +121,16 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
type="button"
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-[#b9dcff] bg-[#eaf4ff] font-bold text-[#0f5fa8] before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-[#1e88e5]"
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
? "border-primary bg-secondary font-bold text-primary before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
setActiveKey(item.key);
if (item.key === "infoSearch") {
@@ -72,15 +138,30 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
return;
}
onOpenPopup(item.key);
const popupWindow = onOpenPopup(item.key);
if (!popupWindow) {
setActiveKey(null);
return;
}
const closeWatcher = window.setInterval(() => {
if (!popupWindow.closed) return;
window.clearInterval(closeWatcher);
setActiveKey((currentKey) =>
currentKey === item.key ? null : currentKey,
);
}, 300);
}}
>
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-[#1e88e5]" : "text-[#64748b]"
}`}
/>
<span className="break-keep whitespace-normal">{item.label}</span>
{item.renderIcon ? item.renderIcon({ isActive }) : null}
{Icon ? (
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-primary" : "text-label"
}`}
/>
) : null}
<span className="whitespace-nowrap">{item.label}</span>
</button>
);
})}
@@ -90,7 +171,7 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
<button
type="button"
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#64748b] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] hover:text-[#1e88e5]"
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-border bg-background text-label shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-accent hover:text-primary"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
@@ -1,7 +1,9 @@
// 하수관로
export const MsSwagePplCol = [
{ id: 'fclt_mng_no', title: '시설관리번호' },
{ id: 'mngInstNm', title: '관리기관' },
{ id: 'inst_mng_no', title: '기관관리번호' },
{ id: 'flowDirectionBasis', title: '유하방향 근거' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'elapsedYears', title: '경과년도' },
{ id: 'mapsht_no', title: '도엽번호' },
@@ -33,6 +35,7 @@ export const MsSwagePplCol2 = [
{ id: 'rowNumber', title: '일련\n번호' },
{ id: 'fcltMngNo', title: '시설\n관리번호' },
{ id: 'instMngNo', title: '기관\n관리번호' },
{ id: 'flowDirectionBasis', title: '유하방향\n근거' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
{ id: "swtrmt_zone_nm", title: "처리구역" },
@@ -0,0 +1,142 @@
"use client";
import { useMemo, useState } from "react";
import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes";
type Props = {
detail: FloodHistoryItem;
onClose: () => void;
position: {
left: number;
top: number;
};
};
type DetailTab = "year" | "disaster" | "rainfall";
const renderRows = (rows: Array<[string, string]>) => (
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[42%]" />
<col className="w-[58%]" />
</colgroup>
<tbody>
{rows.map(([label, value], index) => (
<tr key={`${label}-${index}`}>
<th className="border border-[#d8e3ee] bg-[#f8fbfe] px-3 py-[7px] text-left text-[1.02rem] font-semibold text-[#36506b]">
{label}
</th>
<td className="border border-[#d8e3ee] bg-white px-3 py-[7px] text-[1.02rem] text-[#1f2937]">
{value}
</td>
</tr>
))}
</tbody>
</table>
);
export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) {
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
const [activeTab, setActiveTab] = useState<DetailTab>("year");
const yearRows = useMemo(
() => [
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수년도", `${detail.floodYear}년`],
["등급", detail.gradeLabel],
["침수구역", detail.districtName],
] as Array<[string, string]>,
[detail],
);
const disasterRows = useMemo(
() => [
["침수재해명", detail.disasterName],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수원인상세명", detail.causeDetailName],
["침수시작년월일", detail.startDate],
["침수종료년월일", detail.endDate],
["침수시작시간", detail.startTime],
] as Array<[string, string]>,
[detail],
);
const rainfallRows = useMemo(
() => [
["침수시작일자", detail.startDate],
["침수종료일자", detail.endDate],
["침수시작시간", detail.startTime],
["침수종료시간", detail.endTime],
["침수시간", detail.floodHour],
["강우시작시간", detail.rainStartTime],
["강우종료시간", detail.rainEndTime],
["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`],
["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`],
["침수시간", detail.floodHour],
["침수원인상세코드", detail.causeDetailCode],
["피해금액", detail.damageAmount],
] as Array<[string, string]>,
[detail],
);
const currentRows = activeTab === "year"
? yearRows
: activeTab === "disaster"
? disasterRows
: rainfallRows;
return (
<div
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-[#cfdbe8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.18)]"
style={{
left: `${position.left}px`,
top: `${position.top}px`,
}}
>
<div className="flex h-[28px] items-center justify-between border-b border-[#d8e3ee] bg-[linear-gradient(180deg,#ffffff_0%,#f6faff_100%)] px-[10px]">
<div className="text-[10px] font-bold tracking-[-0.01em] text-[#243b53]">상세정보</div>
<button
type="button"
className="rounded-[4px] px-[4px] text-[13px] leading-none text-[#7b8ba1] transition hover:bg-[#eef5fb] hover:text-[#35597d]"
onClick={onClose}
>
×
</button>
</div>
<div className="border-b border-[#d8e3ee] bg-[#f8fbfe] px-[6px] pt-[6px]">
<div className="flex gap-[4px]">
{[
["year", "년도탭"],
["disaster", "재해명탭"],
["rainfall", "침수발생강우탭"],
].map(([tabId, label]) => {
const isActive = activeTab === tabId;
return (
<button
key={tabId}
type="button"
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
isActive
? "border-[#b8d4f0] border-b-white bg-white text-[#1f5d99]"
: "border-[#d8e3ee] bg-[#f3f7fb] text-[#5f7388] hover:bg-[#eef5fb]"
}`}
onClick={() => setActiveTab(tabId as DetailTab)}
>
{label}
</button>
);
})}
</div>
</div>
<div className="bg-white p-[6px]">
<div className="overflow-hidden rounded-[6px] border border-[#d8e3ee] bg-white">
{renderRows(currentRows)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,408 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import useStore from "@/store/useStore";
import {
buildFloodHistoryYearOptions,
buildFloodHistoryTableRows,
FLOOD_HISTORY_DEFAULT_FILTERS,
FLOOD_HISTORY_DOWNLOAD_HEADERS,
FLOOD_HISTORY_ITEMS,
FLOOD_HISTORY_PAGE_SIZE,
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
formatFloodArea,
formatFloodDepth,
normalizeFloodHistoryFilters,
type FloodHistoryFilters,
type FloodHistoryItem,
} from "./floodHistoryTypes";
const resolveOptionText = (
code: string,
options: Array<{ text: string; val: string | number }>,
prefixLength?: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized || normalized === "0") return "";
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
};
const GRID_COLUMNS = [
{ id: "rowNo", title: "NO", width: "64px", align: "center" },
{ id: "ctpvNm", title: "시도", width: "90px", align: "center" },
{ id: "sggNm", title: "시군구", width: "100px", align: "center" },
{ id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" },
{ id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" },
{ id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" },
{ id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" },
{ id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" },
{ id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" },
{ id: "disasterName", title: "침수재해명", width: "190px", align: "left" },
{ id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" },
{ id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" },
{ id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" },
{ id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" },
{ id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" },
{ id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" },
{ id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" },
] as const;
const buildDownloadRows = (rows: FloodHistoryItem[]) =>
rows.map((row, index) => ({
rowNo: index + 1,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
exmnYearDisplay: `${row.exmnYear}년`,
avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(row.floodAreaHa),
causeDetailDisplay: row.causeDetailName,
startDateDisplay: row.startDate,
endDateDisplay: row.endDate,
startTimeDisplay: row.startTime,
endTimeDisplay: row.endTime,
causeCodeDisplay: row.causeDetailCode,
districtNameDisplay: row.districtName,
damageAmountDisplay: row.damageAmount,
floodHourDisplay: row.floodHour,
floodYearDisplay: `${row.floodYear}년`,
}));
export default function FloodHistoryPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다.
const [filters, setFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
// 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다.
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
const [currentPage, setCurrentPage] = useState(1);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const floodYearOptions = useMemo(
() => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS),
[],
);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
[filters.ctpvCd, sgg],
);
const filteredRows = useMemo(() => {
const normalized = normalizeFloodHistoryFilters(appliedFilters);
const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2);
const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5);
const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR");
return FLOOD_HISTORY_ITEMS.filter((item) => {
if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false;
if (selectedSggName && item.sggNm !== selectedSggName) return false;
if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false;
if (
disasterToken &&
!item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) &&
!item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken)
) {
return false;
}
return true;
}).sort((left, right) => {
if (left.floodYear !== right.floodYear) {
return Number(right.floodYear) - Number(left.floodYear);
}
return left.disasterName.localeCompare(right.disasterName, "ko");
});
}, [appliedFilters, ctpv, sgg]);
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE;
const end = start + FLOOD_HISTORY_PAGE_SIZE;
return buildFloodHistoryTableRows(
filteredRows.slice(start, end),
currentPage,
FLOOD_HISTORY_PAGE_SIZE,
);
}, [currentPage, filteredRows]);
const handleSearch = () => {
const normalized = normalizeFloodHistoryFilters(filters);
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
alert("시도를 먼저 선택하세요.");
return;
}
if (normalized.floodYear && normalized.floodYear.length !== 4) {
alert("침수발생년도는 4자리 연도로 입력하세요.");
return;
}
setAppliedFilters(normalized);
setCurrentPage(1);
setSelectedHistoryId("");
};
const handleDownload = async () => {
if (filteredRows.length === 0) {
alert("다운로드할 침수발생이력 데이터가 없습니다.");
return;
}
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(filteredRows) as unknown as JSON[],
fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수흔적도"],
headerRange: "A1:Q1",
headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS,
});
};
const handleRowDoubleClick = (row: FloodHistoryItem) => {
window.opener?.postMessage(
{
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
window.close();
};
const handleRowClick = (row: FloodHistoryItem) => {
setSelectedHistoryId(row.historyId);
// popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다.
window.opener?.postMessage(
{
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
};
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">침수흔적도 목록</h1>
<div />
</header>
<section className="sn-popup-section">
<div className="sn-popup-filter-panel-compact">
<div className="grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수발생년도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{floodYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수재해명</div>
<Input
className="sn-popup-input !text-[1.1rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="sn-popup-actions md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !m-[1.2rem] !mt-0 min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<Table variant="secondary" className="w-max min-w-full">
<colgroup>
{GRID_COLUMNS.map((head) => (
<col key={head.id} style={{ width: head.width }} />
))}
</colgroup>
<TableHeader className="font-bold">
<TableRow>
<TableHead rowSpan={2} className="whitespace-nowrap">NO</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시군구</TableHead>
<TableHead colSpan={4} className="whitespace-nowrap">강우 분석정보</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수재해명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수시간</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수지구명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세코드</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">평균침수위</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수면적</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">조사년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">피해금액</TableHead>
</TableRow>
<TableRow>
<TableHead className="whitespace-nowrap">침수시작일자</TableHead>
<TableHead className="whitespace-nowrap">침수종료일자</TableHead>
<TableHead className="whitespace-nowrap">침수시작시간</TableHead>
<TableHead className="whitespace-nowrap">침수종료시간</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-center">
{pagedRows.length > 0 ? (
pagedRows.map((row) => {
const isSelected = selectedHistoryId === row.historyId;
return (
<TableRow
key={row.historyId}
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row-hover"}`}
onClick={() => handleRowClick(row)}
onDoubleClick={() => handleRowDoubleClick(row)}
>
<TableCell>{row.rowNo}</TableCell>
<TableCell>{row.ctpvNm}</TableCell>
<TableCell>{row.sggNm}</TableCell>
<TableCell>{row.startDateDisplay}</TableCell>
<TableCell>{row.endDateDisplay}</TableCell>
<TableCell>{row.startTimeDisplay}</TableCell>
<TableCell>{row.endTimeDisplay}</TableCell>
<TableCell className="text-left">{row.disasterName}</TableCell>
<TableCell>{row.floodYearDisplay}</TableCell>
<TableCell>{row.floodHourDisplay}</TableCell>
<TableCell className="text-left">{row.districtNameDisplay}</TableCell>
<TableCell>{row.causeCodeDisplay}</TableCell>
<TableCell className="text-left">{row.causeDetailDisplay}</TableCell>
<TableCell className="text-right">{row.avgFloodDepthDisplay}</TableCell>
<TableCell className="text-right">{row.floodAreaDisplay}</TableCell>
<TableCell>{row.exmnYearDisplay}</TableCell>
<TableCell className="text-right">{row.damageAmountDisplay}</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={GRID_COLUMNS.length} className="sn-popup-empty-cell h-24 text-center">
조회된 데이터가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{filteredRows.length > 0 ? (
<UiPageNations
total={filteredRows.length}
cur={currentPage}
pageSize={FLOOD_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
/>
) : null}
<div className="sn-popup-help-text">
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
</div>
</div>
</section>
</main>
);
}
@@ -0,0 +1,458 @@
"use client";
export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup";
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLOOD_HISTORY_PAGE_SIZE = 10;
export type FloodHistoryFilters = {
ctpvCd: string;
sggCd: string;
floodYear: string;
disasterName: string;
};
export type FloodHistoryDepthZone = {
zoneId: string;
depthLabel: string;
color: string;
ring: [number, number][];
};
export type FloodHistoryItem = {
historyId: string;
ctpvNm: string;
sggNm: string;
floodYear: string;
exmnYear: string;
disasterName: string;
districtName: string;
floodAreaHa: number;
avgFloodDepthM: number;
floodDepthM: number;
gradeLabel: string;
startDate: string;
endDate: string;
startTime: string;
endTime: string;
floodHour: string;
causeDetailCode: string;
causeDetailName: string;
damageAmount: string;
rainStartTime: string;
rainEndTime: string;
totalRainfallMm: number;
rainfallIntensityMmHr: number;
areaRing: [number, number][];
depthZones: FloodHistoryDepthZone[];
};
export type FloodHistoryTableRow = FloodHistoryItem & {
rowNo: number;
exmnYearDisplay: string;
avgFloodDepthDisplay: string;
floodAreaDisplay: string;
causeDetailDisplay: string;
startDateDisplay: string;
endDateDisplay: string;
startTimeDisplay: string;
endTimeDisplay: string;
causeCodeDisplay: string;
districtNameDisplay: string;
damageAmountDisplay: string;
floodHourDisplay: string;
floodYearDisplay: string;
};
export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = {
ctpvCd: "0",
sggCd: "0",
floodYear: "",
disasterName: "",
};
export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "NO",
ctpvNm: "시도",
sggNm: "시군구",
disasterName: "침수재해명",
exmnYearDisplay: "조사년도",
avgFloodDepthDisplay: "평균침수위",
floodAreaDisplay: "침수면적",
causeDetailDisplay: "침수원인상세",
startDateDisplay: "침수시작일자",
endDateDisplay: "침수종료일자",
startTimeDisplay: "침수시작시간",
endTimeDisplay: "침수종료시간",
causeCodeDisplay: "침수원인상세코드",
districtNameDisplay: "침수지구명",
damageAmountDisplay: "피해금액",
floodHourDisplay: "침수시간",
floodYearDisplay: "침수년도",
};
export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [
{ color: "#fff27a", label: "0.0~0.5m" },
{ color: "#c2f13d", label: "0.5~1.0m" },
{ color: "#67d8ff", label: "1.0~2.0m" },
{ color: "#8f8bff", label: "2.0~5.0m" },
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
const toRadian = (degree: number) => (degree * Math.PI) / 180;
const createIrregularRing = ({
centerX,
centerY,
radiusX,
radiusY,
scales,
rotationDeg = 0,
}: {
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
scales: number[];
rotationDeg?: number;
}): [number, number][] => {
const rotation = toRadian(rotationDeg);
const points = scales.map((scale, index) => {
const angle = rotation + (Math.PI * 2 * index) / scales.length;
const jitterX = radiusX * scale * Math.cos(angle);
const jitterY = radiusY * scale * Math.sin(angle);
return [centerX + jitterX, centerY + jitterY] as [number, number];
});
return [...points, points[0]];
};
const createDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FloodHistoryDepthZone[] => {
return [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 5200,
centerY: centerY - 2600,
radiusX: 3600,
radiusY: 2900,
rotationDeg: -18,
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
}),
},
{
zoneId: `${historyId}-depth-2`,
depthLabel: "0.5~1.0m",
color: "#c2f13d",
ring: createIrregularRing({
centerX: centerX + 2600,
centerY: centerY - 1200,
radiusX: 4300,
radiusY: 3600,
rotationDeg: 12,
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
}),
},
{
zoneId: `${historyId}-depth-3`,
depthLabel: "1.0~2.0m",
color: "#67d8ff",
ring: createIrregularRing({
centerX: centerX - 600,
centerY: centerY + 2600,
radiusX: 5600,
radiusY: 4700,
rotationDeg: 8,
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
}),
},
{
zoneId: `${historyId}-depth-4`,
depthLabel: "2.0~5.0m",
color: "#8f8bff",
ring: createIrregularRing({
centerX: centerX + 4200,
centerY: centerY + 5200,
radiusX: 3200,
radiusY: 2500,
rotationDeg: 26,
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
}),
},
{
zoneId: `${historyId}-depth-5`,
depthLabel: "5.0m 이상",
color: "#f2528b",
ring: createIrregularRing({
centerX: centerX + 600,
centerY: centerY - 5200,
radiusX: 1500,
radiusY: 1350,
rotationDeg: -10,
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
};
const createHistory = (
item: Omit<FloodHistoryItem, "areaRing" | "depthZones"> & {
centerX: number;
centerY: number;
width: number;
height: number;
},
): FloodHistoryItem => {
const { centerX, centerY, width, height, ...detail } = item;
return {
...detail,
areaRing: createIrregularRing({
centerX,
centerY,
radiusX: width / 2,
radiusY: height / 2,
rotationDeg: 9,
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
}),
depthZones: createDepthZones(detail.historyId, centerX, centerY),
};
};
export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [
createHistory({
historyId: "FH-2024-001",
ctpvNm: "부산광역시",
sggNm: "중구",
floodYear: "2024",
exmnYear: "2025",
disasterName: "중앙동 저지대 침수",
districtName: "중앙동 저지대",
floodAreaHa: 1.8,
avgFloodDepthM: 0.62,
floodDepthM: 0.62,
gradeLabel: "1등급",
startDate: "2024-07-18",
endDate: "2024-07-18",
startTime: "14:10",
endTime: "18:20",
floodHour: "4.2시간",
causeDetailCode: "FC-01",
causeDetailName: "집중강우",
damageAmount: "120,000,000원",
rainStartTime: "13:50",
rainEndTime: "18:20",
totalRainfallMm: 91,
rainfallIntensityMmHr: 31,
centerX: 1122000,
centerY: 1705000,
width: 18000,
height: 14000,
}),
createHistory({
historyId: "FH-2023-002",
ctpvNm: "경상남도",
sggNm: "창원시",
floodYear: "2023",
exmnYear: "2024",
disasterName: "팔용동 공단 배수지연 침수",
districtName: "팔용동 공단",
floodAreaHa: 4.8,
avgFloodDepthM: 1.92,
floodDepthM: 1.92,
gradeLabel: "2등급",
startDate: "2023-08-11",
endDate: "2023-08-11",
startTime: "09:40",
endTime: "15:10",
floodHour: "5.5시간",
causeDetailCode: "FC-04",
causeDetailName: "배수시설 처리능력 초과",
damageAmount: "340,000,000원",
rainStartTime: "08:55",
rainEndTime: "15:05",
totalRainfallMm: 138,
rainfallIntensityMmHr: 42,
centerX: 1081000,
centerY: 1778000,
width: 25000,
height: 17000,
}),
createHistory({
historyId: "FH-2022-003",
ctpvNm: "울산광역시",
sggNm: "남구",
floodYear: "2022",
exmnYear: "2023",
disasterName: "태화강 제내지 도로 침수",
districtName: "여천동 하류부",
floodAreaHa: 3.1,
avgFloodDepthM: 1.15,
floodDepthM: 1.15,
gradeLabel: "2등급",
startDate: "2022-09-06",
endDate: "2022-09-06",
startTime: "07:20",
endTime: "12:40",
floodHour: "5.3시간",
causeDetailCode: "FC-02",
causeDetailName: "하천 수위 상승",
damageAmount: "210,000,000원",
rainStartTime: "06:30",
rainEndTime: "11:50",
totalRainfallMm: 117,
rainfallIntensityMmHr: 37,
centerX: 1168000,
centerY: 1811000,
width: 22000,
height: 16000,
}),
createHistory({
historyId: "FH-2021-004",
ctpvNm: "부산광역시",
sggNm: "서구",
floodYear: "2021",
exmnYear: "2022",
disasterName: "서대신동 급경사지 유출 침수",
districtName: "서대신동 일원",
floodAreaHa: 2.4,
avgFloodDepthM: 0.85,
floodDepthM: 0.85,
gradeLabel: "1등급",
startDate: "2021-07-07",
endDate: "2021-07-07",
startTime: "16:00",
endTime: "20:10",
floodHour: "4.1시간",
causeDetailCode: "FC-03",
causeDetailName: "우수관로 월류",
damageAmount: "175,000,000원",
rainStartTime: "15:20",
rainEndTime: "19:50",
totalRainfallMm: 104,
rainfallIntensityMmHr: 29,
centerX: 1111000,
centerY: 1709000,
width: 20000,
height: 14500,
}),
createHistory({
historyId: "FH-2020-005",
ctpvNm: "부산광역시",
sggNm: "영도구",
floodYear: "2020",
exmnYear: "2021",
disasterName: "청학동 해안도로 월파 침수",
districtName: "청학동 해안부",
floodAreaHa: 1.2,
avgFloodDepthM: 0.48,
floodDepthM: 0.48,
gradeLabel: "1등급",
startDate: "2020-10-22",
endDate: "2020-10-22",
startTime: "03:10",
endTime: "06:30",
floodHour: "3.3시간",
causeDetailCode: "FC-06",
causeDetailName: "해안 월파",
damageAmount: "82,000,000원",
rainStartTime: "02:40",
rainEndTime: "05:50",
totalRainfallMm: 55,
rainfallIntensityMmHr: 18,
centerX: 1129000,
centerY: 1695000,
width: 15000,
height: 12000,
}),
createHistory({
historyId: "FH-2019-006",
ctpvNm: "경상남도",
sggNm: "김해시",
floodYear: "2019",
exmnYear: "2020",
disasterName: "장유 배수분구 내수 침수",
districtName: "장유 배수분구",
floodAreaHa: 2.9,
avgFloodDepthM: 0.96,
floodDepthM: 0.96,
gradeLabel: "1등급",
startDate: "2019-09-21",
endDate: "2019-09-21",
startTime: "11:30",
endTime: "16:15",
floodHour: "4.8시간",
causeDetailCode: "FC-05",
causeDetailName: "배수펌프 지연 가동",
damageAmount: "143,000,000원",
rainStartTime: "10:45",
rainEndTime: "15:50",
totalRainfallMm: 88,
rainfallIntensityMmHr: 24,
centerX: 1098000,
centerY: 1734000,
width: 21000,
height: 15000,
}),
];
export const normalizeFloodHistoryFilters = (
filters: FloodHistoryFilters,
): FloodHistoryFilters => ({
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
disasterName: String(filters.disasterName ?? "").trim(),
});
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
export const buildFloodHistoryTableRows = (
items: FloodHistoryItem[],
page: number,
pageSize = FLOOD_HISTORY_PAGE_SIZE,
): FloodHistoryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
exmnYearDisplay: `${item.exmnYear}년`,
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
causeDetailDisplay: item.causeDetailName,
startDateDisplay: item.startDate,
endDateDisplay: item.endDate,
startTimeDisplay: item.startTime,
endTimeDisplay: item.endTime,
causeCodeDisplay: item.causeDetailCode,
districtNameDisplay: item.districtName,
damageAmountDisplay: item.damageAmount,
floodHourDisplay: item.floodHour,
floodYearDisplay: `${item.floodYear}년`,
}));
export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) =>
Array.from(new Set(items.map((item) => item.floodYear)))
.sort((left, right) => Number(right) - Number(left));
const resolveFloodHistoryNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
const resolveFloodHistoryLayerType = (node: any) =>
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
export const isFloodHistoryNode = (node: any) => {
const name = resolveFloodHistoryNodeName(node);
const layerType = resolveFloodHistoryLayerType(node);
return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE;
};
@@ -0,0 +1,341 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@common/components/ui";
import { getData } from "@/components/lib/api";
import { VerticalTable } from "@/components/ui/verticalTable";
import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
type Props = {
change: GisEditChange;
featureData: any;
onClose: () => void;
onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void;
};
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
};
const EMPTY_SELECT_OPTION = { value: "", label: "선택" };
const PIPE_BASIC_INFO_FIELDS = [
{ id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true },
{ id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true },
{ id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true },
{ id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" },
{ id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" },
{ id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" },
{ id: "prlgVl", sourceId: "prlg_vl", title: "연장(m)", type: "input" },
{
id: "usgCd",
sourceId: "usg_cd",
title: "용도",
type: "select",
required: true,
options: [
EMPTY_SELECT_OPTION,
...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
],
},
{
id: "mqltCd",
sourceId: "mqlt_cd",
title: "재질",
type: "select",
options: [
EMPTY_SELECT_OPTION,
...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
],
},
{ id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" },
{ id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" },
{ id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" },
{ id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" },
{ id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" },
{ id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" },
{ id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" },
{ id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" },
{ id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" },
{ id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" },
];
const PIPE_INPUT_INFO_FIELDS = [
{ id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] },
{ id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] },
{ id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] },
];
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id);
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id);
const toEditableText = (value: unknown) => {
const text = String(value ?? "").trim();
return text === "-" ? "" : text;
};
const isInvalidPipeLookupKey = (value: string) =>
!value || value === "-" || /^NEW-/i.test(value) || /^(null|undefined)$/i.test(value);
const toDateInputValue = (value: unknown) => {
const text = toEditableText(value).replaceAll(".", "").replaceAll("-", "");
if (!/^\d{8}$/.test(text)) return "";
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
};
const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", "");
const getObjectValue = (values: Record<string, any>, field: { id: string; sourceId?: string }) =>
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
if (Object.prototype.hasOwnProperty.call(values, fieldId)) {
acc[fieldId] = values[fieldId];
}
return acc;
}, {});
const normalizeBasicFormValues = (values: Record<string, any>) => {
const nextValues = pickFields(values, PIPE_VISIBLE_BASIC_FIELD_IDS);
if (Object.prototype.hasOwnProperty.call(nextValues, "instlYmd")) {
nextValues.instlYmd = toDateInputValue(nextValues.instlYmd);
}
return nextValues;
};
const buildBaseFeatureValues = (featureData: any) => {
const resolveUsageCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
return matched?.val.trim() ?? raw;
};
const resolveMaterialCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
return matched?.val.trim() ?? raw;
};
const sourceValues = featureData?.values_ ?? featureData ?? {};
return PIPE_BASIC_INFO_FIELDS.reduce<Record<string, any>>((acc, field) => {
const rawValue = getObjectValue(sourceValues, field);
if (field.id === "usgCd") {
acc[field.id] = resolveUsageCode(rawValue);
return acc;
}
if (field.id === "mqltCd") {
acc[field.id] = resolveMaterialCode(rawValue);
return acc;
}
if (field.id === "instlYmd") {
acc[field.id] = toDateInputValue(rawValue);
return acc;
}
acc[field.id] = toEditableText(rawValue);
return acc;
}, {});
};
const getPipeUsageLabel = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw;
};
const buildInitialInputDraft = (change: GisEditChange) => ({
instlMbdyCd: toEditableText(change.basicInfo?.instlMbdyCd ?? ""),
swtrmtZoneNm: toEditableText(change.basicInfo?.swtrmtZoneNm ?? ""),
mltvZoneNm: toEditableText(change.basicInfo?.mltvZoneNm ?? ""),
mltvDtdvNm: toEditableText(change.basicInfo?.mltvDtdvNm ?? ""),
lineCd: toEditableText(change.basicInfo?.lineCd ?? ""),
floCd: toEditableText(change.basicInfo?.floCd ?? ""),
layCd: toEditableText(change.basicInfo?.layCd ?? ""),
});
export default function GisEditPipeSessionPopup({
change,
featureData,
onClose,
onSave,
}: Props) {
const isNewPipe = change.editType === "ADD";
const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]);
const [basicInfoTableData, setBasicInfoTableData] = useState<Record<string, any>>({});
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
buildInitialInputDraft(change),
);
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
useEffect(() => {
let isMounted = true;
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
if (!isMounted) return;
setMngInstOptions(Array.isArray(result) ? result : []);
} catch {
if (isMounted) setMngInstOptions([]);
}
};
fetchMngInstOptions();
return () => {
isMounted = false;
};
}, []);
const mngInstSelectOptions = useMemo(
() => {
const sortedOptions = [...mngInstOptions].sort((a, b) =>
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
"ko",
),
);
return [
EMPTY_SELECT_OPTION,
...sortedOptions.map((option) => ({
value: String(option.mngInstCd ?? "").trim(),
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
})),
];
},
[mngInstOptions],
);
const basicInfoFields = useMemo(
() =>
PIPE_BASIC_INFO_FIELDS.map((field) =>
field.id === "mngInstCd"
? { ...field, options: mngInstSelectOptions }
: field,
),
[mngInstSelectOptions],
);
useEffect(() => {
const initialBasicValues: Record<string, any> = {
...baseFeatureValues,
...normalizeBasicFormValues(change.basicInfo ?? {}),
};
setBasicInfoTableData(initialBasicValues);
setInputInfoTableData(buildInitialInputDraft(change));
}, [baseFeatureValues, change.changeId, isNewPipe]);
useEffect(() => {
if (isNewPipe) return;
const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim();
const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim();
if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd)) return;
let isMounted = true;
const fetchPipeInputInfo = async () => {
try {
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
const safeMngInstCd = encodeURIComponent(mngInstCd);
const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`);
if (!isMounted || !result) return;
setInputInfoTableData((previous) => ({
...previous,
...pickFields(result, PIPE_VISIBLE_INPUT_FIELD_IDS),
...pickFields(change.basicInfo ?? {}, PIPE_VISIBLE_INPUT_FIELD_IDS),
}));
} catch {
// Optional pipe input details should not block the edit session.
}
};
fetchPipeInputInfo();
return () => {
isMounted = false;
};
}, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]);
const buildMergedDraft = (
nextBasicInfoTableData: Record<string, any>,
nextInputInfoTableData: Record<string, any>,
): GisEditPipeBasicInfo => {
const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS);
const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS);
if (basicDraft.instlYmd) {
basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd);
}
return {
...basicDraft,
...inputDraft,
};
};
const handleBasicInfoChange = (nextFormData: Record<string, any>) => {
setBasicInfoTableData(nextFormData);
onSave(change.changeId, buildMergedDraft(nextFormData, inputInfoTableData));
};
const handleInputInfoChange = (nextFormData: Record<string, any>) => {
setInputInfoTableData(nextFormData);
onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData));
};
const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]);
const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]);
const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? "");
return (
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
<div className="card-header bg-primary flex items-center justify-between relative p-4">
<div className="flex items-center space-x-4 flex-1">
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
하수관로
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
</h3>
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="overflow-y-auto overflow-x-hidden flex-1">
<VerticalTable
headName="기본정보"
tableData={basicInfoTableRows}
headlist={basicInfoFields}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => {}}
isEditable={true}
onFormDataChange={handleBasicInfoChange}
/>
<VerticalTable
headName="입력정보"
tableData={inputInfoTableRows}
headlist={PIPE_INPUT_INFO_FIELDS}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => {}}
isEditable={true}
onFormDataChange={handleInputInfoChange}
/>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
"use client";
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
type Props = GisEditSessionSnapshot & {
onRemoveFocusedChange: () => void;
onRemoveLastChange: () => void;
onClearChanges: () => void;
onFocusChange: (changeId: string) => void;
onOpenPopup?: () => void;
variant?: "inline" | "popup";
className?: string;
};
const TOOL_LABELS: Record<GisEditTool, string> = {
ADD: "추가",
MOVE: "이동",
VERTEX_EDIT: "꼭지점편집",
DELETE: "삭제",
};
const buildChangeItemClassName = (isFocused: boolean) =>
`block w-full rounded border px-2 py-2 text-left text-[12px] transition ${
isFocused
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
}`;
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
const getChangeSummary = (change: GisEditSessionSnapshot["changes"][number]) => {
if (change.attributeOnly) return "변경한 상세정보를 확인하세요.";
if (change.editType === "ADD") return "추가한 객체 위치를 확인하세요.";
if (change.editType === "MOVE") return "객체를 드래그해 위치를 조정하세요.";
if (change.editType === "VERTEX_EDIT") return "점을 옮기거나 선분을 끌어 추가하세요.";
return "삭제할 객체가 맞는지 확인하세요.";
};
type ChangeListProps = {
changes: GisEditSessionSnapshot["changes"];
focusedChangeId: string | null;
onFocusChange: (changeId: string) => void;
};
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
<>
{changes.map((change, index) => (
<button
key={change.changeId}
type="button"
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
onClick={() => onFocusChange(change.changeId)}
>
<div className="flex items-center gap-1">
<div className="shrink-0 whitespace-nowrap text-[11px] font-semibold leading-4 text-[#173f67]">
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
<span className="whitespace-nowrap rounded-full border border-[#a9d9bd] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#16834f]">
정보저장
</span>
) : null}
{focusedChangeId === change.changeId ? (
<span className="whitespace-nowrap rounded-full border border-[#b8d3ef] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#176db8]">
지도 미리보기 선택
</span>
) : null}
</div>
</div>
<div className="mt-1 truncate text-[11px] text-[#526173]">{getChangeSummary(change)}</div>
</button>
))}
</>
);
export default function GisEditSessionPanel({
isEditMode,
selectedLayerName,
activeTool,
changes,
canUndo,
focusedChangeId,
onRemoveFocusedChange,
onRemoveLastChange,
onClearChanges,
onFocusChange,
onOpenPopup,
variant = "inline",
className = "",
}: Props) {
const isCompactInline = variant === "inline" && changes.length === 0;
return (
<div
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${isCompactInline ? "h-[58px] p-2" : "p-4"} ${className}`.trim()}
>
<div className={`flex shrink-0 justify-between gap-3 ${isCompactInline ? "mb-0 items-center" : "mb-3 items-start"}`}>
<div className="min-w-0">
{variant === "inline" ? (
<div className="mb-1 text-[14px] font-bold text-[#173f67]">편집 세션</div>
) : null}
<div className="truncate text-[12px] text-[#667485]">
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
</div>
</div>
{onOpenPopup ? (
<button
type="button"
className="flex shrink-0 items-center gap-1 rounded border border-[#b8d3ef] bg-white px-2.5 py-1.5 text-[11px] font-semibold text-[#176db8] transition hover:bg-[#eef7ff]"
onClick={onOpenPopup}
title="편집 세션을 기존 윈도우 팝업으로 열기"
>
<span aria-hidden="true" className="text-[12px] leading-none">↗</span>
<span>팝업</span>
</button>
) : null}
</div>
<div className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2`}>
<button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
disabled={!focusedChangeId}
onClick={onRemoveFocusedChange}
>
취소
</button>
<button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
disabled={!canUndo}
onClick={onRemoveLastChange}
>
뒤로가기
</button>
<button
type="button"
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
disabled={!canUndo}
onClick={onClearChanges}
>
초기화
</button>
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
총 {changes.length}건
</div>
</div>
<div className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}>
{changes.length === 0 ? (
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
저장할 편집 변경분이 없습니다.
</div>
) : (
<div className="h-full overflow-y-auto pr-1">
<div className="space-y-2">
<ChangeList
changes={changes}
focusedChangeId={focusedChangeId}
onFocusChange={onFocusChange}
/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import {
Check,
Move,
PenTool,
Plus,
Trash2,
X,
} from "lucide-react";
import { Button } from "@common/components/ui";
import type { GisEditTool } from "./gisEditTypes";
type Props = {
isEditMode: boolean;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
canUseTools: boolean;
canSave: boolean;
isSaving?: boolean;
statusMessage: string;
allowedTools?: GisEditTool[];
onClose?: () => void;
onToggleSnap: () => void;
onSelectTool: (tool: GisEditTool) => void;
onSave: () => void;
};
const TOOL_BUTTONS: Array<{
tool: GisEditTool;
label: string;
icon: typeof Plus;
}> = [
{ tool: "ADD", label: "추가", icon: Plus },
{ tool: "MOVE", label: "이동", icon: Move },
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
{ tool: "DELETE", label: "삭제", icon: Trash2 },
];
export default function GisEditToolbar({
isEditMode,
activeTool,
isSnapEnabled,
canUseTools,
canSave,
isSaving = false,
statusMessage: _statusMessage,
allowedTools = [],
onClose,
onToggleSnap,
onSelectTool,
onSave,
}: Props) {
if (!isEditMode) return null;
return (
<div data-testid="gis-edit-toolbar" className="absolute bottom-12 left-1/2 z-[60] flex w-[min(960px,calc(100%-2rem))] -translate-x-1/2 flex-col">
<div className="card card-border flex !bg-white py-[1px]">
<div className="flex w-full items-center gap-2 overflow-hidden">
<div className="flex w-[120px] shrink-0 items-center justify-start">
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
</div>
<div className="flex min-w-0 flex-1 justify-center">
<div className="flex items-center gap-1">
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
const isActive = activeTool === tool;
const isToolEnabled =
canUseTools && allowedTools.includes(tool);
if (tool === "VERTEX_EDIT") {
return (
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
<Button
type="button"
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[98px] px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool("VERTEX_EDIT")}
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</Button>
<Button
type="button"
color={isSnapEnabled ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[82px] px-2"
disabled={!canUseTools}
onClick={onToggleSnap}
title="이동과 꼭지점편집에 스냅핑 적용"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>스냅핑</span>
</span>
</Button>
</div>
);
}
return (
<Button
key={tool}
type="button"
color={isActive ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool(tool)}
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Icon className="h-4 w-4" />
<span>{label}</span>
</span>
</Button>
);
})}
</div>
</div>
<div className="flex w-[132px] shrink-0 items-center justify-end gap-1">
<Button
type="button"
color="primary"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
disabled={!canSave || isSaving}
onClick={onSave}
>
{isSaving ? "저장 중" : "저장"}
</Button>
<Button
type="button"
color="white"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
onClick={onClose}
title="하수관로 편집 닫기"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<X className="h-4 w-4" />
<span>닫기</span>
</span>
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
export type GisEditPreviewColors = {
pointColor: string;
lineColor: string;
fillColor: string;
};
export type GisEditFeatureRef = {
fcltMngNo?: string;
mngInstCd?: string;
fid?: string;
};
export type GisEditPipeBasicInfo = {
[key: string]: string | null | undefined;
fcltMngNo?: string | null;
mngInstCd?: string | null;
instMngNo?: string | null;
instlYmd?: string | null;
mapshtNo?: string | null;
pdmtVl?: string | null;
prlgVl?: string | null;
usgCd?: string | null;
mqltCd?: string | null;
avgGrdn?: string | null;
drtwMltvArea?: string | null;
exclnMltvArea?: string | null;
tmptDpth?: string | null;
tmptMhlMngNo?: string | null;
scendDpth?: string | null;
scendMhlMngNo?: string | null;
trflnCnt?: string | null;
vrtcLen?: string | null;
wdthLen?: string | null;
instlMbdyCd?: string | null;
swtrmtZoneNm?: string | null;
mltvZoneNm?: string | null;
mltvDtdvNm?: string | null;
lineCd?: string | null;
floCd?: string | null;
layCd?: string | null;
};
export type GisEditLayerInfo = {
layerId: string;
layerName: string;
geometryType: GisEditGeometryType;
editableYn: "Y" | "N";
allowedTools: GisEditTool[];
previewColors: GisEditPreviewColors;
disabledReason?: string;
};
export type GisEditChange = {
changeId: string;
editType: GisEditTool;
layerId: string;
layerName: string;
objectId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
objectLabel: string;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
featureProperties?: Record<string, unknown> | null;
basicInfo?: GisEditPipeBasicInfo | null;
basicInfoSavedAt?: string | null;
attributeOnly?: boolean;
summary: string;
createdAt: string;
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
};
export type GisEditSavePayload = {
screenId: string;
layerId: string;
layerName: string;
saveTargetType: "VERSION" | "FINAL";
view: {
srs: string;
bbox: string | null;
};
changes: Array<{
changeId: string;
editType: GisEditTool;
objectId?: string;
clientTempId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
attributes?: GisEditPipeBasicInfo | null;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
geometry?: Record<string, unknown> | null;
}>;
};
export type GisEditSessionSnapshot = {
isEditMode: boolean;
selectedLayerName: string;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
changes: GisEditChange[];
lastSavedPayload: GisEditSavePayload | null;
canUndo: boolean;
focusedChangeId: string | null;
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,415 @@
"use client";
import type { ChangeEvent } from "react";
import { useMemo, useRef, useState } from "react";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
type UploadFacilityType = {
facilityTypeCode: string;
facilityTypeName: string;
allowedExtensions: string[];
maxFileSizeMb: number;
};
type UploadHistoryItem = {
uploadId: string;
facilityTypeCode: string;
facilityTypeName: string;
fileName: string;
registeredAt: string;
registeredBy: string;
};
const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [
{
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"],
maxFileSizeMb: 200,
},
];
const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
{
uploadId: "GUP-MOCK-20260504-0001",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260504.zip",
registeredAt: "2026-05-04",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260418-0004",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260418.zip",
registeredAt: "2026-04-18",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260409-0007",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260409.zip",
registeredAt: "2026-04-09",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260331-0010",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260331.zip",
registeredAt: "2026-03-31",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260322-0013",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260322.zip",
registeredAt: "2026-03-22",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260313-0016",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260313.zip",
registeredAt: "2026-03-13",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260304-0019",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260304.zip",
registeredAt: "2026-03-04",
registeredBy: "admin",
},
];
const getFileExtension = (fileName: string) => {
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex < 0) return "";
return fileName.slice(lastDotIndex).toLowerCase();
};
const buildTodayDate = () => new Date().toISOString().slice(0, 10);
const GIS_UPLOAD_PAGE_SIZE = 10;
export default function GisManagementPopupPage() {
// 브라우저 파일 picker는 제어형 input보다 ref 제어가 안전하다.
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>(
INITIAL_UPLOAD_HISTORY,
);
const [selectedUploadId, setSelectedUploadId] = useState<string>(
INITIAL_UPLOAD_HISTORY[0]?.uploadId ?? "",
);
const [currentPage, setCurrentPage] = useState(1);
const selectedFacility = useMemo(
() =>
UPLOAD_FACILITY_TYPES.find(
(facilityType) =>
facilityType.facilityTypeCode === selectedFacilityTypeCode,
) ?? null,
[selectedFacilityTypeCode],
);
const selectedHistory = useMemo(
() =>
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
null,
[selectedUploadId, uploadHistory],
);
const pagedUploadHistory = useMemo(() => {
const startIndex = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE;
return uploadHistory.slice(startIndex, startIndex + GIS_UPLOAD_PAGE_SIZE);
}, [currentPage, uploadHistory]);
const resetSelectedFile = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleFacilityTypeChange = (
event: ChangeEvent<HTMLSelectElement>,
) => {
setSelectedFacilityTypeCode(event.target.value);
resetSelectedFile();
};
const handleOpenFilePicker = () => {
if (!selectedFacility) {
window.alert("시설구분을 먼저 선택하세요.");
return;
}
fileInputRef.current?.click();
};
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const nextFile = event.target.files?.[0] ?? null;
if (!selectedFacility) {
resetSelectedFile();
window.alert("시설구분을 먼저 선택한 뒤 파일을 선택하세요.");
return;
}
if (!nextFile) {
resetSelectedFile();
return;
}
const fileExtension = getFileExtension(nextFile.name);
const isAllowedExtension = selectedFacility.allowedExtensions.includes(
fileExtension,
);
if (!isAllowedExtension) {
resetSelectedFile();
window.alert(
`업로드 가능한 GIS 파일 형식이 아닙니다. 허용 형식: ${selectedFacility.allowedExtensions.join(", ")}`,
);
return;
}
const maxFileSizeInBytes = selectedFacility.maxFileSizeMb * 1024 * 1024;
if (nextFile.size > maxFileSizeInBytes) {
resetSelectedFile();
window.alert(
`파일 크기가 제한을 초과했습니다. 최대 ${selectedFacility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`,
);
return;
}
setSelectedFile(nextFile);
};
const handleUpload = () => {
if (!selectedFacility) {
window.alert("시설구분을 선택하세요.");
return;
}
if (!selectedFile) {
window.alert("업로드할 파일을 선택하세요.");
return;
}
const isDuplicateFile = uploadHistory.some(
(history) =>
history.facilityTypeCode === selectedFacility.facilityTypeCode &&
history.fileName.toLowerCase() === selectedFile.name.toLowerCase(),
);
if (isDuplicateFile) {
window.alert("동일 시설구분에 같은 파일명이 존재합니다.");
return;
}
const nextUploadHistory: UploadHistoryItem = {
uploadId: `GUP-MOCK-${Date.now()}`,
facilityTypeCode: selectedFacility.facilityTypeCode,
facilityTypeName: selectedFacility.facilityTypeName,
fileName: selectedFile.name,
registeredAt: buildTodayDate(),
registeredBy: "admin",
};
setUploadHistory((currentHistory) => [
nextUploadHistory,
...currentHistory,
]);
setCurrentPage(1);
setSelectedUploadId(nextUploadHistory.uploadId);
window.alert("GIS 업로드가 완료되었습니다.");
resetSelectedFile();
};
const handleDownload = () => {
if (!selectedHistory) {
window.alert("다운로드할 파일을 선택하세요.");
return;
}
window.alert(`${selectedHistory.fileName} 다운로드를 요청했습니다.`);
};
return (
<main className="sn-popup-modal-page">
<section className="sn-popup-modal-shell max-w-[920px] shadow-[0_16px_36px_rgba(31,59,87,0.10)] backdrop-blur-[2px]">
<header className="sn-popup-modal-header">
<h1 className="sn-popup-modal-title">
GIS 관리
</h1>
</header>
<div className="px-[16px] pb-[16px] pt-[10px]">
<div className="sn-popup-panel-title mb-[10px] mt-[4px]">
GIS 업로드
</div>
<div className="mb-[8px] flex items-center justify-between gap-[12px] px-[2px]">
<div className="flex min-w-0 flex-1 items-center gap-[10px]">
<select
className="sn-popup-input-sm h-[32px] w-[110px] shrink-0"
value={selectedFacilityTypeCode}
onChange={handleFacilityTypeChange}
aria-label="시설구분"
>
<option value="">시설구분</option>
{UPLOAD_FACILITY_TYPES.map((facilityType) => (
<option
key={facilityType.facilityTypeCode}
value={facilityType.facilityTypeCode}
>
{facilityType.facilityTypeName}
</option>
))}
</select>
<Input
value={selectedFile?.name ?? ""}
placeholder="선택된 파일 없음"
readOnly
aria-label="선택 파일명"
className="sn-popup-input-sm h-[32px] min-w-0 flex-1 py-0 !text-[1.02rem] shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<div className="flex shrink-0 items-center gap-[10px]">
<Button
type="button"
size="sm"
className="sn-popup-primary-button-sm h-[32px] min-w-[56px]"
onClick={handleOpenFilePicker}
>
찾기
</Button>
<Button
type="button"
size="sm"
className="sn-popup-primary-button-sm h-[32px] min-w-[64px]"
onClick={handleUpload}
>
업로드
</Button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={
selectedFacility?.allowedExtensions.join(",") ??
".zip,.shp,.geojson,.json,.dxf"
}
onChange={handleFileSelected}
/>
<div className="mb-[8px] flex items-center justify-between px-[2px]">
<div className="sn-popup-panel-title">
GIS 업로드 현황
</div>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button-sm h-[32px] min-w-[82px] px-[10px]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
<div className="sn-popup-card shadow-[0_6px_16px_rgba(31,59,87,0.06),inset_0_1px_0_rgba(255,255,255,0.78)]">
<Table variant="secondary" className="text-[1.02rem]">
<TableHeader className="!static bg-transparent">
<TableRow className="border-0">
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
no
</TableHead>
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
시설구분
</TableHead>
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
파일명
</TableHead>
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
등록일자
</TableHead>
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
등록자
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedUploadHistory.map((history, index) => {
const isSelected = history.uploadId === selectedUploadId;
const backgroundColor = isSelected
? "var(--primary-transparent)"
: index % 2 === 0
? "var(--background)"
: "var(--secondary)";
const rowNumber =
(currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1;
return (
<TableRow
key={history.uploadId}
className="border-0 cursor-pointer"
onClick={() => setSelectedUploadId(history.uploadId)}
style={{ backgroundColor }}
>
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
{rowNumber}
</TableCell>
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
{history.facilityTypeName}
</TableCell>
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
{history.fileName}
</TableCell>
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
{history.registeredAt}
</TableCell>
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
{history.registeredBy}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? (
<div className="mt-[10px] flex justify-center">
<UiPageNations
total={uploadHistory.length}
cur={currentPage}
pageSize={GIS_UPLOAD_PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
) : null}
</div>
</section>
</main>
);
}
@@ -0,0 +1,345 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import {
INFTWT_RESULT_ROW_SELECT_MESSAGE,
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
type InftwtResultRow,
} from "./inftwtResultTypes";
const INFTWT_RESULT_PAGE_SIZE = 10;
const INFTWT_RESULT_HEADLIST = [
{ id: "rowNumber", title: "일련번호" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
{ id: "aplcnYm", title: "적용연월" },
{ id: "drtwSwtcRt", title: "오수 전환율" },
{ id: "anlsInfo", title: "침입수 분석정보" },
{ id: "regDt", title: "분석일자" },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
] as const;
type InftwtResultFilters = {
ctpvCd: string;
sggCd: string;
sggName: string;
swtrmtFcltNm: string;
aplcnYmFrom: string;
aplcnYmTo: string;
};
const createDefaultAplcnYm = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}${month}`;
};
const DEFAULT_FILTERS: InftwtResultFilters = {
ctpvCd: "0",
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
aplcnYmFrom: `${new Date().getFullYear()}01`,
aplcnYmTo: createDefaultAplcnYm(),
};
const postRowSelectMessage = (row: InftwtResultRow) => {
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
JSON.stringify({
row,
timestamp: Date.now(),
}),
);
} catch (error) {
console.warn("침입수 분석결과 선택 동기화 저장에 실패했습니다.", error);
}
}
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
row,
},
window.location.origin,
);
};
export default function InftwtResultPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
const [selectedRow, setSelectedRow] = useState<InftwtResultRow | null>(null);
const [rows, setRows] = useState<InftwtResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: InftwtResultFilters) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(INFTWT_RESULT_PAGE_SIZE),
sort: "inftwtAnlsSn,desc",
});
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
const nextRows = content
.map((item: Record<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
} as InftwtResultRow;
})
.filter((item: InftwtResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(Number(response?.totalElements ?? nextRows.length));
setCurrentPage(page);
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters);
} catch (error) {
console.error("침입수 분석결과 목록 조회 중 오류:", error);
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
setSelectedIndex(-1);
setSelectedRow(null);
await loadRows(page, filters);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
setSelectedRow(row);
postRowSelectMessage(row);
};
const handleDownload = async () => {
if (!selectedRow?.inftwtAnlsSn) {
alert("다운로드할 분석결과를 먼저 선택해주세요.");
return;
}
try {
setIsLoading(true);
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
const excelRows = Array.isArray(response?.content) ? response.content : [];
await downloadExcelWithKoreanHeaders({
data: excelRows as JSON[],
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
headerMapping: {
inftwtAnlsSn: "침입수분석\n일련번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
},
});
} catch (error) {
console.error("침입수 분석결과 다운로드 중 오류:", error);
alert("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">침입수 분석결과</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="rounded-md border border-border bg-secondary p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<InftwtResultRow>
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
tableData={rows}
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
pageSize={INFTWT_RESULT_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
cellClassName={(_, headId) =>
headId === "anlsInfo"
? "whitespace-normal break-all leading-[1.35]"
: ""
}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
);
}
@@ -0,0 +1,48 @@
"use client";
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
export const INFTWT_RESULT_OVERLAY_TYPE = "INFTWT_RESULT";
export const INFTWT_RESULT_ROW_SELECT_STORAGE_KEY = "SN_INFTWT_RESULT_ROW_SELECT_STORAGE";
export type InftwtResultRow = {
rowNumber: number;
inftwtAnlsSn: number;
ctpvCd?: string;
ctpvNm: string;
sggCd?: string;
sggNm: string;
swtrmtFcltNm: string;
aplcnYm: string;
drtwSwtcRt?: number | string;
anlsInfo: string;
regDt?: string;
rgtrNm?: string;
scsYn?: string;
};
export type InftwtResultMapViewPipe = {
pplMngNo: string;
fcltMngNo: string;
hasOilq: boolean;
};
export type InftwtResultMapViewResponse = {
analysis: {
inftwtAnlsSn: number;
aplcnYm: string;
ctpvCd?: string;
sggCd?: string;
swtrmtFcltNm?: string;
anlsInfo?: string;
};
focusTargetType: "ZONE" | "PIPE_EXTENT";
focusTargetName: string;
focusLayerType?: "tb_ms_swage_prcs_zone" | "tb_ms_swage_ppl";
focusFid?: string;
focusFallbackApplied?: boolean;
zoneGeometry: Record<string, unknown>;
pipeGeometry: Record<string, unknown>;
pipes: InftwtResultMapViewPipe[];
pipeCount: number;
oilqPipeCount: number;
};
@@ -0,0 +1,508 @@
"use client";
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import Stroke from "ol/style/Stroke.js";
import Fill from "ol/style/Fill.js";
import Style from "ol/style/Style.js";
import { intersects as intersectsExtent } from "ol/extent";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
import {
INFTWT_RESULT_OVERLAY_TYPE,
INFTWT_RESULT_ROW_SELECT_MESSAGE,
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
type InftwtResultMapViewResponse,
type InftwtResultRow,
} from "./inftwtResultTypes";
type UseInftwtResultMapParams = {
gisMaps: MutableRefObject<any | null>;
setFeatureData: (value: any) => void;
setIsOpen: (value: boolean) => void;
setIsSearchModalOpen: (value: boolean) => void;
};
const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER";
const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER";
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
const zoneStyle = new Style({
fill: new Fill({
color: "rgba(16, 185, 129, 0.06)",
}),
stroke: new Stroke({
color: "rgba(5, 150, 105, 0.72)",
width: 2,
lineDash: [10, 8],
}),
zIndex: 19991,
});
const buildPipeStyle = (hasOilq: boolean) => {
const innerColor = hasOilq ? "rgba(236, 72, 153, 0.98)" : "rgba(14, 165, 233, 0.98)";
const haloWidth = hasOilq ? 11 : 5;
const innerWidth = hasOilq ? 6 : 2.5;
const dashPattern = hasOilq ? [18, 9] : undefined;
return [
new Style({
stroke: new Stroke({
color: "rgba(15, 23, 42, 0.58)",
width: hasOilq ? haloWidth + 4 : haloWidth + 2,
lineCap: "round",
lineJoin: "round",
}),
zIndex: hasOilq ? 20000 : 19999,
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 255, 255, 0.98)",
width: haloWidth,
lineCap: "round",
lineJoin: "round",
}),
zIndex: hasOilq ? 20001 : 20000,
}),
new Style({
stroke: new Stroke({
color: innerColor,
width: innerWidth,
lineCap: "round",
lineJoin: "round",
lineDash: dashPattern,
}),
zIndex: hasOilq ? 20002 : 20001,
}),
];
};
const getFeatureCollection = (payload: any) => {
if (payload?.type === "FeatureCollection") return payload;
if (payload?.data?.type === "FeatureCollection") return payload.data;
return { type: "FeatureCollection", features: [] };
};
const normalizeProjectionName = (value: string) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "EPSG:4326";
if (trimmed.startsWith("EPSG:")) return trimmed;
const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/);
return match?.[1] ? `EPSG:${match[1]}` : trimmed;
};
const resolveFeatureCollectionProjection = (payload: any) => {
const featureCollection = getFeatureCollection(payload);
const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim();
return normalizeProjectionName(crsName || GisApp.TO_PROJ);
};
const getOverlayFlag = (data: any) =>
String(
data?.get?.("analysisOverlayType") ??
data?.values_?.analysisOverlayType ??
data?.analysisOverlayType ??
"",
).trim();
const getOverlayBasePplMngNo = (data: any) =>
String(
data?.get?.("fcltMngNo") ??
data?.values_?.fcltMngNo ??
data?.values_?.fclt_mng_no ??
data?.fcltMngNo ??
data?.fclt_mng_no ??
"",
).trim();
const getOverlayAnalysisPplMngNo = (data: any) =>
String(
data?.get?.("pplMngNo") ??
data?.values_?.pplMngNo ??
data?.pplMngNo ??
"",
).trim();
const getOverlayAnalysisSn = (data: any) =>
Number(
data?.get?.("inftwtAnlsSn") ??
data?.values_?.inftwtAnlsSn ??
data?.inftwtAnlsSn ??
0,
);
const getOverlayAplcnYm = (data: any) =>
String(
data?.get?.("aplcnYm") ??
data?.values_?.aplcnYm ??
data?.aplcnYm ??
"",
).trim();
const getOverlayAnalysisInfo = (data: any) =>
String(
data?.get?.("analysisInfo") ??
data?.values_?.analysisInfo ??
data?.analysisInfo ??
"",
).trim();
const getFirstFeatureId = (payload: any) => {
const featureCollection = getFeatureCollection(payload);
const firstFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : null;
return String(firstFeature?.id ?? "").trim();
};
const getOilqPipeExtent = (features: any[]) => {
const oilqFeatures = features.filter((feature) => Boolean(feature?.get?.("hasOilq")));
if (oilqFeatures.length === 0) return null;
let nextExtent: [number, number, number, number] | null = null;
oilqFeatures.forEach((feature) => {
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
if (!Array.isArray(geometryExtent) || geometryExtent.some((value: number) => !Number.isFinite(value))) {
return;
}
if (!nextExtent) {
nextExtent = [...geometryExtent] as [number, number, number, number];
return;
}
nextExtent = [
Math.min(nextExtent[0], geometryExtent[0]),
Math.min(nextExtent[1], geometryExtent[1]),
Math.max(nextExtent[2], geometryExtent[2]),
Math.max(nextExtent[3], geometryExtent[3]),
];
});
return nextExtent;
};
export const useInftwtResultMap = ({
gisMaps,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
}: UseInftwtResultMapParams) => {
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
const zoneSourceRef = useRef<VectorSource | null>(null);
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
const pipeSourceRef = useRef<VectorSource | null>(null);
const mapViewCacheRef = useRef<Map<number, InftwtResultMapViewResponse>>(new Map());
const [activeMapView, setActiveMapView] = useState<InftwtResultMapViewResponse | null>(null);
const clearLayers = useCallback(() => {
zoneSourceRef.current?.clear?.();
pipeSourceRef.current?.clear?.();
zoneLayerRef.current?.changed?.();
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, [gisMaps]);
const ensureZoneLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (zoneLayerRef.current && zoneSourceRef.current) {
return zoneSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 19991,
});
layer.set("id", INFTWT_ZONE_LAYER_ID);
layer.set("lyrNm", "移⑥엯?섎텇??泥섎━援ъ뿭");
layer.set("lyrTyNm", "tb_ms_swage_prcs_zone");
layer.set("clickPsblYn", "N");
layer.setVisible?.(true);
map.addLayer(layer);
zoneLayerRef.current = layer;
zoneSourceRef.current = source;
return source;
}, [gisMaps]);
const ensurePipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (pipeLayerRef.current && pipeSourceRef.current) {
return pipeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 19997,
});
layer.set("id", INFTWT_PIPE_LAYER_ID);
layer.set("lyrNm", "침입수분석 결과관로");
layer.set("lyrTyNm", "tb_ms_swage_ppl");
layer.set("clickPsblYn", "Y");
layer.setVisible?.(true);
map.addLayer(layer);
pipeLayerRef.current = layer;
pipeSourceRef.current = source;
return source;
}, [gisMaps]);
const fitToRenderedFeatures = useCallback(
(mapView?: InftwtResultMapViewResponse | null) => {
const map = gisMaps.current;
const zoneExtent = zoneSourceRef.current?.getExtent?.();
const pipeExtent = pipeSourceRef.current?.getExtent?.();
const pipeFeatures = pipeSourceRef.current?.getFeatures?.() ?? [];
const oilqPipeExtent = getOilqPipeExtent(pipeFeatures);
const hasPipeFeatures = pipeFeatures.length > 0;
const hasZoneFeatures = Boolean(zoneSourceRef.current?.getFeatures?.().length);
const preferZone = mapView?.focusTargetType === "ZONE" && hasZoneFeatures;
const zoneExtentIsFinite = Array.isArray(zoneExtent) && zoneExtent.every((value: number) => Number.isFinite(value));
const pipeExtentIsFinite = Array.isArray(pipeExtent) && pipeExtent.every((value: number) => Number.isFinite(value));
const hasPipeInsideZone =
preferZone && zoneExtentIsFinite
? pipeFeatures.some((feature: any) => {
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
return Array.isArray(geometryExtent)
&& geometryExtent.every((value: number) => Number.isFinite(value))
&& intersectsExtent(zoneExtent as [number, number, number, number], geometryExtent as [number, number, number, number]);
})
: false;
const shouldUseZoneExtent = preferZone && (hasPipeInsideZone || !hasPipeFeatures);
const extent = oilqPipeExtent
? oilqPipeExtent
: shouldUseZoneExtent
? zoneExtent
: hasPipeFeatures
? pipeExtent
: hasZoneFeatures
? zoneExtent
: null;
if (!map || !extent || extent.some((value: number) => !Number.isFinite(value))) {
return;
}
map.getView?.().fit?.(extent, {
duration: 350,
padding: DEFAULT_RESULT_FIT_PADDING,
maxZoom: oilqPipeExtent ? 17 : shouldUseZoneExtent ? 15 : 16,
constrainResolution: true,
nearest: true,
});
},
[gisMaps],
);
const moveByRegionCodes = useCallback(
(ctpvCd?: string, sggCd?: string) => {
const normalizedSggCd = String(sggCd ?? "").trim();
if (normalizedSggCd && normalizedSggCd !== "0") {
const sigunguCenter = getSigunguCenter(normalizedSggCd);
if (sigunguCenter) {
moveMapToCenter(gisMaps, sigunguCenter, 12);
return true;
}
}
const normalizedCtpvCd = String(ctpvCd ?? "").trim();
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
if (sidoCenterCoordinate) {
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
return true;
}
}
return false;
},
[gisMaps],
);
const moveToStableFocus = useCallback(
async (mapView: InftwtResultMapViewResponse) => {
if ((mapView.oilqPipeCount ?? 0) > 0) {
fitToRenderedFeatures(mapView);
return;
}
const activeRow = activeResultRowRef.current;
const movedByRegion = moveByRegionCodes(
activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd,
activeRow?.sggCd ?? mapView.analysis?.sggCd,
);
if (movedByRegion) return;
fitToRenderedFeatures(mapView);
},
[fitToRenderedFeatures, moveByRegionCodes],
);
const renderMapView = useCallback(
async (mapView: InftwtResultMapViewResponse) => {
const zoneSource = ensureZoneLayer();
const pipeSource = ensurePipeLayer();
if (!zoneSource || !pipeSource) return;
clearLayers();
const zoneDataProjection = resolveFeatureCollectionProjection(mapView.zoneGeometry);
const zoneFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.zoneGeometry), {
dataProjection: zoneDataProjection,
featureProjection: GisApp.TO_PROJ,
});
zoneFeatures.forEach((feature: any) => feature.setStyle(zoneStyle));
zoneSource.addFeatures(zoneFeatures);
zoneSource.changed?.();
zoneLayerRef.current?.setVisible?.(true);
zoneLayerRef.current?.changed?.();
const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry);
const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), {
dataProjection: pipeDataProjection,
featureProjection: GisApp.TO_PROJ,
});
pipeFeatures.forEach((feature: any) => {
const hasOilq = Boolean(feature.get?.("hasOilq"));
feature.setStyle(buildPipeStyle(hasOilq));
});
pipeSource.addFeatures(pipeFeatures);
pipeSource.changed?.();
pipeLayerRef.current?.setVisible?.(true);
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", {
focusTargetType: mapView.focusTargetType,
zoneFeatureCount: zoneFeatures.length,
pipeFeatureCount: pipeFeatures.length,
});
await moveToStableFocus(mapView);
},
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
);
const handleRowSelect = useCallback(
async (row: InftwtResultRow) => {
try {
activeResultRowRef.current = row;
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
const cachedMapView = mapViewCacheRef.current.get(row.inftwtAnlsSn);
let mapView = cachedMapView;
if (!mapView) {
const payload = await getRawJson(`/sewerage-service/api/v1/inftwtAnlsRslt/mapView/${row.inftwtAnlsSn}`);
mapView = (payload?.data ?? payload) as InftwtResultMapViewResponse;
mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView);
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(INFTWT_PROC_SEARCH_EVENT, {
detail: {
ctpvCd: row.ctpvCd ?? "0",
sggCd: row.sggCd ?? "0",
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
suppressMapMove: (mapView.oilqPipeCount ?? 0) > 0,
},
}),
);
}
setActiveMapView(mapView);
void renderMapView(mapView);
} catch (error) {
console.error("移⑥엯??遺꾩꽍寃곌낵 吏€???곌퀎 以??ㅻ쪟:", error);
alert("移⑥엯??遺꾩꽍寃곌낵瑜?吏€?꾩뿉 ?곌퀎?섏? 紐삵뻽?듬땲??");
}
},
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
);
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
void handleRowSelect(event.data.row as InftwtResultRow);
};
const handleStorageEvent = (event: StorageEvent) => {
if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow };
if (!payload?.row) return;
void handleRowSelect(payload.row);
} catch (error) {
console.warn("移⑥엯??遺꾩꽍寃곌낵 storage ?숆린??媛믪쓣 ?댁꽍?섏? 紐삵뻽?듬땲??", error);
}
};
window.addEventListener("message", handlePopupMessage);
window.addEventListener("storage", handleStorageEvent);
return () => {
window.removeEventListener("message", handlePopupMessage);
window.removeEventListener("storage", handleStorageEvent);
};
}, [handleRowSelect]);
useEffect(() => {
return () => {
clearLayers();
};
}, [clearLayers]);
const handleOverlayFeatureSelect = useCallback((data: any) => {
if (getOverlayFlag(data) !== INFTWT_RESULT_OVERLAY_TYPE) {
return false;
}
const overlayAnalysisSn = getOverlayAnalysisSn(data);
const basePplMngNo = getOverlayBasePplMngNo(data);
const analysisPplMngNo = getOverlayAnalysisPplMngNo(data);
const currentRow = activeResultRowRef.current;
const popupWindow = openWindowPopup("infilChart", {
pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""),
inftwtAnlsSn: overlayAnalysisSn || currentRow?.inftwtAnlsSn || 0,
aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "",
analysisInfo:
getOverlayAnalysisInfo(data) ||
currentRow?.anlsInfo ||
activeMapView?.analysis?.anlsInfo ||
"",
resultOnly: "Y",
});
if (!popupWindow) {
alert("?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.");
}
return true;
}, [activeMapView]);
return {
activeMapView,
clearLayers,
handleOverlayFeatureSelect,
};
};
@@ -42,6 +42,9 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"materialName",
] as const;
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
export default function PipeAnalysisDetailPopupPage() {
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
@@ -118,7 +121,7 @@ export default function PipeAnalysisDetailPopupPage() {
setCurrentPage(page);
} catch (error) {
console.error("관로현황 조회 중 오류:", error);
alert("관로현황 조회 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
setRows([]);
setTotal(0);
} finally {
@@ -188,7 +191,7 @@ export default function PipeAnalysisDetailPopupPage() {
});
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert("관로현황 다운로드 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
} finally {
setIsLoading(false);
}
@@ -244,18 +247,18 @@ export default function PipeAnalysisDetailPopupPage() {
}, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]);
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">관로현황{titleSuffix}</h1>
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
<section className="sn-popup-section overflow-y-auto">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">용도</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">용도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
@@ -273,9 +276,9 @@ export default function PipeAnalysisDetailPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관재질</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">관재질</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
@@ -293,9 +296,9 @@ export default function PipeAnalysisDetailPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 시작</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 시작</div>
<Input
className="h-10 !text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
@@ -310,9 +313,9 @@ export default function PipeAnalysisDetailPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 종료</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
<Input
className="h-10 !text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
@@ -32,6 +33,7 @@ const SUMMARY_FILE_PREFIX: Record<string, string> = {
MATERIAL: "관로분석_관재질별",
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
};
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
const toExcelColumnName = (index: number) => {
let current = index;
@@ -57,6 +59,9 @@ const formatGridNumber = (value: unknown) => {
});
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
export default function PipeAnalysisPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
@@ -70,6 +75,7 @@ export default function PipeAnalysisPopupPage() {
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
@@ -138,10 +144,11 @@ export default function PipeAnalysisPopupPage() {
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
setDraftFilters(scopedFilters);
setAppliedFilters(scopedFilters);
setSummaryCurrentPage(1);
await fetchSummary(scopedFilters);
} catch (error) {
console.error("관로분석 집계 조회 중 오류:", error);
alert("관로분석 집계 조회 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
setRows([]);
setMaterialColumns([]);
} finally {
@@ -216,7 +223,7 @@ export default function PipeAnalysisPopupPage() {
return exportRow;
});
await downloadExcelWithKoreanHeaders({
data: exportRows as JSON[],
data: exportRows as unknown as JSON[],
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로분석 현황"],
headerRange,
@@ -225,7 +232,7 @@ export default function PipeAnalysisPopupPage() {
});
} catch (error) {
console.error("관로분석 집계 다운로드 중 오류:", error);
alert("관로분석 집계 다운로드 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
}
};
@@ -280,6 +287,25 @@ export default function PipeAnalysisPopupPage() {
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const visibleYearMaterialGroups = useMemo(
() =>
yearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[summaryPageOffset, yearMaterialGroups],
);
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
? yearMaterialGroups.length
: rows.length;
const sggOptions = useMemo(
() =>
@@ -290,18 +316,18 @@ export default function PipeAnalysisPopupPage() {
);
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">관로분석</h1>
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로분석</h1>
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<section className="sn-popup-section overflow-y-auto">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.ctpvCd}
disabled={scope.ctpvLocked}
onChange={(event) =>
@@ -323,9 +349,9 @@ export default function PipeAnalysisPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.sggCd}
disabled={scope.sggLocked}
onChange={(event) =>
@@ -346,9 +372,9 @@ export default function PipeAnalysisPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">처리구역</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.swtrmtZoneNm}
onChange={(event) =>
setDraftFilters((current) => ({
@@ -366,9 +392,9 @@ export default function PipeAnalysisPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">분석구분</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.analysisType}
onChange={(event) =>
setDraftFilters((current) => ({
@@ -410,11 +436,8 @@ export default function PipeAnalysisPopupPage() {
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<>
<div className="card-header">
<h3 className="hidden">
寃쎄낵?꾨룄&?ъ쭏 愿€濡쒗쁽?? 珥?{yearMaterialGroups.length.toLocaleString("ko-KR")}嫄?
</h3>
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
@@ -424,7 +447,7 @@ export default function PipeAnalysisPopupPage() {
{headList.map((head) => (
<TableHead
key={head.id}
className={`border border-border bg-[#eaf3fb] px-4 py-[1.2rem] text-[1.3rem] font-medium text-[#647784] ${
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
head.align === "right" ? "text-right" : "text-center"
}`}
>
@@ -434,8 +457,8 @@ export default function PipeAnalysisPopupPage() {
</TableRow>
</TableHeader>
<TableBody className="text-center">
{yearMaterialGroups.length > 0 ? (
yearMaterialGroups.flatMap((group) =>
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
{rowIndex === 0 ? (
@@ -467,7 +490,7 @@ export default function PipeAnalysisPopupPage() {
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable ? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2" : ""
isClickable ? "cursor-pointer font-semibold text-primary underline underline-offset-2" : ""
}`}
onClick={() => {
if (isClickable) {
@@ -484,14 +507,24 @@ export default function PipeAnalysisPopupPage() {
)
) : (
<TableRow>
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-gray-500">
議고쉶??寃곌낵媛€ ?놁뒿?덈떎.
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-label">
조회 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={yearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</>
) : null}
@@ -499,21 +532,26 @@ export default function PipeAnalysisPopupPage() {
className={appliedFilters.analysisType === "YEAR_MATERIAL"
? "hidden"
: useHorizontalScrollTable
? "[&>div.relative.w-full]:overflow-x-auto [&>div.relative.w-full]:overflow-y-visible"
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
: ""}
headName={
appliedFilters.analysisType === "YEAR_MATERIAL"
? "경과년도&재질 관로현황"
: "관로분석현황"
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
}
tableData={rows}
tableData={pagedRows}
headlist={headList}
pageSize={Math.max(rows.length, 1)}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
total={rows.length}
cellClick={(index, columnId) => handleCellClick(index, columnId)}
onPageChange={() => undefined}
curPage={1}
cellClick={(index, columnId) =>
handleCellClick(
typeof index === "number" ? index + summaryPageOffset : index,
columnId,
)}
onPageChange={setSummaryCurrentPage}
curPage={summaryCurrentPage}
widthFixed={!useHorizontalScrollTable}
isHideCount={true}
cellClassName={(row, columnId) =>
!row.__isNoZone && row.__cellFilters[columnId]
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
@@ -46,6 +46,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
};
const { BIZ_SE_CD, BIZ_TY_CD } = useStore();
const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim();
const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim();
// 마운트 및 업데이트 시 API 호출
@@ -53,9 +55,18 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
if (featureData) {
setFormData(featureData);
}
}, [featureData]);
useEffect(() => {
if (!requestMngInstCd || !requestFcltMngNo) {
setInspectionData([]);
setCleaningData([]);
setRepairData([]);
return;
}
const fetchAll = async () => {
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${featureData?.mng_inst_cd}&fcltMngNo=${featureData?.fclt_mng_no}&page=0&size=1`);
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${requestMngInstCd}&fcltMngNo=${requestFcltMngNo}&page=0&size=1`);
let inspectionArr: operationDataType[] = [];
let cleaningArr: operationDataType[] = [];
@@ -104,8 +115,13 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
setCleaningData(cleaningArr);
setRepairData(repairArr);
};
fetchAll();
}, [featureData]);
fetchAll().catch((error) => {
console.error("운영 이력 조회 실패:", error);
setInspectionData([]);
setCleaningData([]);
setRepairData([]);
});
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
@@ -160,4 +176,4 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
)
}
export default MsSwagePpl
export default MsSwagePpl
@@ -33,9 +33,9 @@ const metricGroups: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
}[] = [
{ title: "평균매설년도(년)", field: "avgInstlYear" },
{ title: "최장매설년도(년)", field: "oldestInstlYear" },
{ title: "최신매설년도(년)", field: "latestInstlYear" },
{ title: "평균경과년도(년)", field: "avgInstlYear" },
{ title: "최장경과년도(년)", field: "oldestInstlYear" },
{ title: "최신경과년도(년)", field: "latestInstlYear" },
];
const MetricVerticalGrid = ({
@@ -1,9 +1,9 @@
import {useState, useEffect} from 'react';
import { getData } from '@/components/lib/api';
import { useState, useEffect } from "react";
import moment from "moment";
import { getData } from "@/components/lib/api";
export type TInftwtAnls = {
inftwtAnlsSn:number;
inftwtAnlsSn: number;
aplcnYm: string;
ctpvCd: string;
sggCd: string;
@@ -15,18 +15,20 @@ export type TInftwtAnls = {
atflMpngNm: string;
atflOilqNm: string;
atflNm: string;
drtwSwtcRt:number;
drtwSwtcRt: number;
anlsInfo: string;
scsYn: string;
};
export type TInftwtAnlsRslt = {
inftwtAnlsSn: number;
pplMngNo: string;
anlsTm: string;
predcOilq:number;
}
predcOilq: number;
};
export type TRdiiAnls = {
rainInfwtAnlsSn:number;
rainInfwtAnlsSn: number;
aplcnYm: string;
ctpvCd: string;
sggCd: string;
@@ -38,234 +40,259 @@ export type TRdiiAnls = {
atflMpngNm: string;
atflRainNm: string;
atflNm: string;
drtwSwtcRt:number;
drtwSwtcRt: number;
anlsInfo: string;
scsYn: string;
srcNm: string; // 출처이름
srcNm: string;
};
export type TRdiiAnlsRslt = {
rainInfwtAnlsSn: number;
pplMngNo: string;
anlsTm: string;
predcOilq:number;
minq:number;
}
predcOilq: number;
minq: number;
};
export type TOilqData = {
inftwtAnlsSn: number; // 침입수분석 일련번호
pplMngNo: string; // 관로관리번호
mtrngDt: string; // 계측일시
oilq: number; // 유량
}
inftwtAnlsSn: number;
pplMngNo: string;
mtrngDt: string;
oilq: number;
};
export type TRainData = {
rainInfwtAnlsSn: number;
mtrngDt: string;
amrf: number; // 강우량
}
amrf: number;
};
function roundTo(n: number, digits: number): number {
const factor = Math.pow(10, digits);
return Math.round(n * factor) / factor;
}
const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string, hidden?: boolean }[] => {
const yyyymms1 : { text: string; val: string; hidden?: boolean }[] = yyyymms.map(dateStr => {
const year = dateStr.slice(0, 4);
const month = dateStr.slice(4, 6);
return {
text: `${year}-${month}`,
val: dateStr
};
});
yyyymms1.unshift({ val: '0', text: '선택', hidden: true });
return yyyymms1;
};
const selectAplcnYms = async(gubun:string) => { // 상수사용월
try {
const queryString = '';
const result = await getData(`v1/${gubun}/aplcnYm${queryString ? `?${queryString}` : ''}`);
if (result && result.length > 0) {
return getOptionsAplcnYm(result);
}
} catch (error) {
console.error(error);
}
const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string; hidden?: boolean }[] => {
const mapped: { text: string; val: string; hidden?: boolean }[] = yyyymms.map((dateStr) => ({
text: `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}`,
val: dateStr,
}));
mapped.unshift({ val: "0", text: "선택", hidden: true });
return mapped;
};
export const useAplcnYms = (gubun:string) => {
const [data, setData] = useState<{ text: string; val: string }[]>([]);
const selectAplcnYms = async (gubun: string) => {
if (!gubun) return [];
try {
const result = await getData(`v1/${gubun}/aplcnYm`);
if (Array.isArray(result) && result.length > 0) {
return getOptionsAplcnYm(result);
}
} catch {
return [];
}
return [];
};
export const useAplcnYms = (gubun: string) => {
const [data, setData] = useState<{ text: string; val: string; hidden?: boolean }[]>([]);
useEffect(() => {
const fetch = async (gubun:string) => {
if (!gubun) {
setData([]);
return;
}
const fetch = async () => {
const result = await selectAplcnYms(gubun);
if (result) setData(result);
setData(result ?? []);
};
fetch(gubun);
void fetch();
}, [gubun]);
return data;
};
export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number, hidden?: boolean }[] => {
const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({
export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number; hidden?: boolean }[] => {
const options: { text: string; val: number; hidden?: boolean }[] = anlsArr.map((anls) => ({
text: anls.anlsInfo,
val: anls.inftwtAnlsSn,
}));
options.unshift({ val: 0, text: '선택', hidden: true });
options.unshift({ val: 0, text: "선택", hidden: true });
return options;
};
export const selectInftwtAnls = async(aplcnYm:string) => { // 침입수분석
export const selectInftwtAnls = async (aplcnYm: string) => {
try {
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : '';
const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ''}`);
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : "";
const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ""}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
return result.content;
}
} catch {
return [];
}
return [];
};
export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({
export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]) => {
return arr.map((anlsRslt) => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("HH시"),
val: roundTo(anlsRslt.predcOilq, 2),
}));
return options;
};
export const selectInftwtAnlsRslt = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수분석 결과
export const selectInftwtAnlsRslt = async (inftwtAnlsSn: number, pplMngNo: string) => {
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
if (!inftwtAnlsSn || !pplMngNo) return [];
const result = await getData(`v1/inftwtAnlsRslt/ppl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
return result.content;
}
} catch {
return [];
}
return [];
};
export const getChartDataOilq = (arr: TOilqData[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({
export const getChartDataOilq = (arr: TOilqData[]) => {
return arr.map((anlsRslt) => ({
label: anlsRslt.mtrngDt,
val: roundTo(anlsRslt.oilq, 2),
}));
return options;
};
export const selectOilq = async(inftwtAnlsSn:number, pplMngNo:string) => { // 유량
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const selectPplInftwtQty = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수량
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`);
if (result.length > 0) {
return result[0].avg_day_flow;
} else {
return 0; // 데이터가 없을 경우 0 반환
}
} catch (error) {
console.error(error);
}
};
//----------
export const selectPplRdiiQty = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // rdii값
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) {
console.warn("rainInfwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${pplMngNo}`);
if (result?.sustaintime > 0) {
return result;
}
} catch (error) {
console.error(error);
}
};
export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number, hidden?: boolean }[] => {
const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({
export const selectOilq = async (inftwtAnlsSn: number, pplMngNo: string) => {
try {
if (!inftwtAnlsSn || !pplMngNo) return [];
const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch {
return [];
}
return [];
};
export const selectPplInftwtQty = async (inftwtAnlsSn: number, pplMngNo: string) => {
try {
if (!inftwtAnlsSn || !pplMngNo) return 0;
const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`);
if (Array.isArray(result) && result.length > 0) {
return result[0].avg_day_flow;
}
} catch {
return 0;
}
return 0;
};
const buildPplMngNoCandidates = (pplMngNo: string) => {
const normalized = String(pplMngNo ?? "").trim();
if (!normalized) return [];
const withoutPrefix = normalized.replace(/^L/, "");
const withPrefix = withoutPrefix ? `L${withoutPrefix}` : "";
return Array.from(new Set([normalized, withPrefix, withoutPrefix].filter(Boolean)));
};
export const selectPplRdiiQty = async (rainInfwtAnlsSn: number, pplMngNo: string) => {
if (!rainInfwtAnlsSn || !pplMngNo) return null;
for (const candidate of buildPplMngNoCandidates(pplMngNo)) {
try {
const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${candidate}`);
if (result) {
return result;
}
} catch {
// resultOnly mode fallback candidates should not raise console noise
}
}
return null;
};
export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number; hidden?: boolean }[] => {
const options: { text: string; val: number; hidden?: boolean }[] = anlsArr.map((anls) => ({
text: anls.anlsInfo,
val: anls.rainInfwtAnlsSn,
}));
options.unshift({ val: 0, text: '선택', hidden: true });
options.unshift({ val: 0, text: "선택", hidden: true });
return options;
};
export const selectRdiiAnls = async(aplcnYm:string) => { // 침입수분석
export const selectRdiiAnls = async (aplcnYm: string) => {
try {
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : '';
const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ''}`);
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : "";
const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ""}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
return result.content;
}
} catch {
return [];
}
return [];
};
export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]): { label: string; lineVal0: number, barVal0:number }[] => {
const options = arr.map(anlsRslt => ({
export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]) => {
return arr.map((anlsRslt) => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"),
lineVal0: roundTo(anlsRslt.predcOilq, 2),
barVal0: 0
barVal0: 0,
}));
return options;
};
export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({
export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]) => {
return arr.map((anlsRslt) => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"),
val: roundTo(anlsRslt.minq, 2),
}));
return options;
};
export const selectRdiiAnlsRslt = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // RDII분석 결과
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) {
console.warn("RdiiAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
export const selectRdiiAnlsRslt = async (rainInfwtAnlsSn: number, pplMngNo: string) => {
if (!rainInfwtAnlsSn || !pplMngNo) return [];
for (const candidate of buildPplMngNoCandidates(pplMngNo)) {
try {
const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${candidate}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const selectRainData = async(rainInfwtAnlsSn:number) => { // 강우데이터
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0)) {
console.warn("RdiiAnlsSn or pplMngNo is not provided");
return [];
}
} catch {
// resultOnly mode fallback candidates should not raise console noise
}
}
return [];
};
export const selectRainData = async (rainInfwtAnlsSn: number) => {
try {
if (!rainInfwtAnlsSn) return [];
const result = await getData(`v1/rdiiAnlsRslt/rainData/${rainInfwtAnlsSn}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
return result.content;
}
} catch {
return [];
}
return [];
};
export const getChartDataRainData = (arr: TRainData[]): { label: string; lineVal0: number, barVal0: number }[] => {
const options = arr.map(data => ({
export const getChartDataRainData = (arr: TRainData[]) => {
return arr.map((data) => ({
label: moment(data.mtrngDt, "YYYY-MM-DD HH:mm:ss").format("MM-DD HH시"),
lineVal0: 0,
barVal0: roundTo(data.amrf,2),
barVal0: roundTo(data.amrf, 2),
}));
return options;
};
@@ -11,11 +11,15 @@ import {
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TInftwtAnls } from './aplcnYms';
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from './aplcnYms';
import * as fn from './aplcnYms';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
pplMngNo?: string;
inftwtAnlsSn?: number;
aplcnYm?: string;
analysisInfo?: string;
resultOnly?: boolean;
};
type DataType = {
@@ -25,8 +29,13 @@ type DataType = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const InfilChart = ({
pplMngNo = '',
inftwtAnlsSn = 0,
aplcnYm = '',
analysisInfo = '',
resultOnly = false,
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
@@ -42,6 +51,7 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt');
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
const isResultOnlyMode = resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const chartForm = CHART_FORM.LINE;
const chartConfig = {
@@ -53,6 +63,28 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
}, [chartData]);
const calculateFallbackInftwtQty = (
inftwtAnlsRslt: TInftwtAnlsRslt[],
oilqRows: TOilqData[],
) => {
const predcValues = inftwtAnlsRslt
.map((item) => Number(item.predcOilq))
.filter((value) => Number.isFinite(value));
const oilqValues = oilqRows
.map((item) => Number(item.oilq))
.filter((value) => Number.isFinite(value));
if (predcValues.length === 0 || oilqValues.length === 0) {
return 0;
}
const avgPredc = predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
const avgOilq = oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
const dailyQty = (avgOilq - avgPredc) * 24;
return Math.round(dailyQty / 10) * 10;
};
const resetState = () => {
setSelectedAplcnYm('');
setInftwtAnlss([]);
@@ -63,6 +95,63 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setInftwtQty('0');
};
const loadResultChart = async (targetInftwtAnlsSn: number) => {
if (!targetInftwtAnlsSn || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
return;
}
try {
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(targetInftwtAnlsSn, normalizedPplMngNo),
fn.selectOilq(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectOilq 에러:', err);
return [];
}),
fn.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectPplInftwtQty 에러:', err);
return 0;
}),
]);
setChartData({
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const fallbackQty = calculateFallbackInftwtQty(inftwtAnlsRslt ?? [], oilq ?? []);
const normalizedQty = Number(qty ?? Number.NaN);
const resolvedQty = Number.isFinite(normalizedQty) && !(normalizedQty === 0 && fallbackQty !== 0)
? normalizedQty
: fallbackQty;
if (!Number.isFinite(normalizedQty) || (normalizedQty === 0 && fallbackQty !== 0)) {
console.warn('Inftwt qty API returned empty or zero; using chart-data fallback qty.', {
apiQty: qty,
fallbackQty,
resultCount: inftwtAnlsRslt?.length ?? 0,
oilqCount: oilq?.length ?? 0,
});
}
setInftwtQty(resolvedQty.toLocaleString('ko-KR'));
} catch (error) {
console.error('침입수 분석결과 차트 조회 중 오류:', error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
}
};
useEffect(() => {
if (!isResultOnlyMode) return;
setSelectedAplcnYm(aplcnYm);
setSelectedInftwtAnls(inftwtAnlsSn);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
void loadResultChart(inftwtAnlsSn);
}, [aplcnYm, inftwtAnlsSn, isResultOnlyMode, normalizedPplMngNo]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -113,31 +202,7 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
return;
}
try {
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(selectedInftwtAnls, normalizedPplMngNo),
fn.selectOilq(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
console.error('selectOilq 에러:', err);
return [];
}),
fn.selectPplInftwtQty(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
console.error('selectPplInftwtQty 에러:', err);
return 0;
}),
]);
setChartData({
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const normalizedQty = Number(qty ?? 0);
setInftwtQty(normalizedQty.toLocaleString('ko-KR'));
} catch (error) {
console.error('infilRsltChart 에러:', error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
}
await loadResultChart(selectedInftwtAnls);
};
const renderInfoInput = (label: string, value?: string | number | null) => (
@@ -154,14 +219,56 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const handleReset = () => {
resetState();
}
};
const renderBody = () => (
<div className="text-black">
<div className="mb-4 flex items-start justify-between">
<div className="text-[18px] font-semibold text-[#111827]" />
const renderResultPanel = () => (
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>상수사용량 적용연월</span>
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>침입수량 산정결과</span>
</div>
<div className="flex items-end gap-3 whitespace-nowrap text-[#222]">
<span className="text-[16px] text-[#666]">침입수량</span>
<div className="flex items-end justify-start gap-2 whitespace-nowrap">
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
<span className="pb-[2px] text-[16px]">m³/d</span>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'침입수량 그래프'}
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
@@ -216,60 +323,14 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
</div>
</section>
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
<div className="mb-6 grid grid-cols-2 gap-5">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
{/* <span className="text-[14px] leading-none">●</span> */}
<span>상수사용량 적용연월</span>
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
{/* <span className="text-[14px] leading-none">●</span> */}
<span>침입수량 산정결과</span>
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-7 text-[#222]">
<span className="text-[16px] text-[#666]">침입수량</span>
<div className='w-[66px] flex items-end justify-start gap-2'>
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
<span className="pb-[2px] text-[16px]">m³/d</span>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'침입수량 그래프'}
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
{renderResultPanel()}
</div>
</div>
);
return (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">침입수 분석</h1>
{renderBody()}
<div className="min-h-dvh bg-white p-3">
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
</div>
);
};
@@ -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<TRdiiAnls[]>([]);
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS,
);
const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null); // 강우유입수분석정보
const [rainInfwtAnlsSn, setRainInfwtAnlsSn] = useState<number>(0);// 강우유입수분석 정보 선택시 사용
const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null);
const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] = useState<number>(0);
const [chartData, setChartData] = useState<LineBarChartData[]>([]);
const [rdiiQty, setRdiiQty] = useState<string>('0'); // 강우유입수량
const [rainTotal, setRainTotal] = useState<string>('0'); // 적용강우량
const [totalVolume, setTotalVolume] = useState<string>('0');// 총체적 m³/mm
const [rdiiQty, setRdiiQty] = useState<string>("0");
const [rainTotal, setRainTotal] = useState<string>("0");
const [totalVolume, setTotalVolume] = useState<string>("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) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
@@ -172,166 +203,128 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
<Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]"
placeholder=""
value={value ?? ''}
value={value ?? ""}
readOnly
/>
</div>
);
const renderBody = () => (
<div className="text-black">
<div className="mb-4 flex items-start justify-between">
<div className="text-[18px] font-semibold text-[#111827]" />
const renderResultPanel = () => (
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">RDII 분석 결과</h3>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>상수사용 적용연월</span>
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM") : "-"}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="shrink-0 whitespace-nowrap text-[14px] font-extrabold text-[#ef4444]">
<span>강우유입수량 분석결과</span>
</div>
<div className="ml-4 flex min-w-0 basis-[60%] flex-col gap-[2px]">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">평균RDII</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rdiiQty}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/d</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">적용 강우량</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rainTotal}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">mm</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">강우량 대비 RDII</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{totalVolume}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/mm</div>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={"RDII 그래프"}
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">데이터가 없습니다.</div>
)}
</div>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,36%)_minmax(0,64%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
강우유입수분석 선택 조건
</h3>
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">강우유입수량 선택 조건</h3>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
상수사용월:
</Label>
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">상수사용월</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={aplcnYms}
value={aplcnYm}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</div>
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
분석정보:
</Label>
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">분석정보:</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={rdiiAnlsInfos}
value={rainInfwtAnlsSn}
value={selectedRainInfwtAnlsSn}
onSelectValue={handleRdiiAnlsSnChange}
/>
</div>
{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 ?? "")}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={fetchFilteredData}>
조회
</Button>
<Button size="lg" onClick={fetchFilteredData}>조회</Button>
&nbsp;&nbsp;
<Button size="lg" color="gray" onClick={resetState}>
초기화
</Button>
<Button size="lg" color="gray" onClick={resetState}>초기화</Button>
</div>
</section>
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
강우유입수분석 결과
</h3>
<div className="mb-6 flex gap-5">
{/* 좌측 카드 */}
<div className="flex min-h-[98px] min-w-0 basis-2/5 grow shrink-[1.4] items-center justify-between rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4">
<div className="text-[16px] font-bold text-[#ef4444]">
상수사용량 적용연월
</div>
<div className="text-[24px] font-extrabold leading-none text-[#111]">
{aplcnYm ? moment(aplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
{/* 우측 카드 */}
<div className="flex min-h-[98px] min-w-0 basis-3/5 grow shrink items-center gap-5 rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4">
{/* 좌측: 제목 (세로 가운데 정렬) */}
<div className="flex min-w-0 basis-[32%] items-center">
<div className="text-[16px] font-bold text-[#ef4444] break-keep">
강우유입수량 분석결과
</div>
</div>
{/* 우측: 지표 영역 */}
<div className="flex min-w-0 basis-[68%] flex-col gap-[2px]">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">
평균RDII
</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">
{rdiiQty}
</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">
m³/d
</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">
적용 강우량
</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">
{rainTotal}
</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">
mm
</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">
강우량 대비 RDII
</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">
{totalVolume}
</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">
m³/mm
</div>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'RDII 그래프'}
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
{renderResultPanel()}
</div>
</div>
);
if (isResultOnlyMode) {
return <div className="min-h-dvh bg-white p-3">{renderResultPanel()}</div>;
}
return (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
{renderBody()}
{renderDefaultBody()}
</div>
);
};
export default React.memo(RdiiChart);
export default React.memo(RdiiChart);
@@ -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<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
@@ -80,6 +85,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [chmbrYdnts, setChmbrYdnts] = useState<string>(''); // 로딩 상태
const linkedFacilityFeatureRef = useRef<any>(null);
const linkedFacilityOriginalStyleRef = useRef<any>(null);
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
// 🎯 관로관리번호 추출
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<string, string>();
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<Record<string, unknown>>((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_
@@ -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<RdiiResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
const [selectedRow, setSelectedRow] = useState<RdiiResultRow | null>(null);
const [rows, setRows] = useState<RdiiResultRow[]>([]);
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<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * 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 (
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">RDII 분석결과</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="rounded-md border border-border bg-secondary p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<RdiiResultRow>
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}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
);
}
@@ -0,0 +1,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<string, unknown>;
pipes: RdiiResultMapViewPipe[];
pipeCount: number;
};
@@ -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<any | null>;
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<RdiiResultRow | null>(null);
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
const pipeSourceRef = useRef<VectorSource | null>(null);
const mapViewCacheRef = useRef<Map<number, RdiiResultMapViewResponse>>(new Map());
const [activeMapView, setActiveMapView] = useState<RdiiResultMapViewResponse | null>(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,
};
};
@@ -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<PipeUseCodeItem[]> | 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<Map<string, string>>(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 (
<div
id="popup"
className="absolute top-0 right-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300"
>
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="flex-1">
<h3 className="text-2xl font-medium text-white">
지반침하정보
<br />
사고번호 : {display(detail.acdntNo)}
</h3>
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<VerticalTable
headName="발생정보"
tableData={summaryTableData}
headlist={summaryHeadList}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={function (): void {
throw new Error("Function not implemented.");
}}
/>
<section className="pb-[1.2rem]">
<div className="card-header !block px-[1.2rem]">
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
침하발생 반경내 관로정보
</h3>
{canShowNearby ? (
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-slate-500">
<span>반경 {radiusMeters}m · </span>
<span className="font-bold text-[#1d4ed8]">{nearbyPipes.length.toLocaleString()}건</span>
<span>, 평균경과 </span>
<span className="font-bold text-[#1d4ed8]">
{displayElapsedYears(nearbyPipeElapsedSummary.average)}
</span>
<span>, 최장경과 </span>
<span className="font-bold text-[#1d4ed8]">
{displayElapsedYears(nearbyPipeElapsedSummary.oldest)}
</span>
</div>
) : null}
</div>
{!canShowNearby ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-amber-200 bg-amber-50 px-3 py-3 text-[1.05rem] text-amber-700">
좌표가 없어 반경 내 하수관로를 조회할 수 없습니다.
{detail.geocodeErrorMessage ? (
<div className="mt-2 break-words text-[0.98rem] text-amber-800">
사유: {display(detail.geocodeErrorMessage)}
</div>
) : null}
</div>
) : nearbyPipes.length === 0 ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-slate-200 bg-slate-50 px-3 py-3 text-[1.05rem] text-slate-600">
반경 내 하수관로 정보가 없습니다.
</div>
) : (
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-0 overflow-hidden">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[34px]" />
<col className="w-[90px]" />
<col className="w-[60px]" />
<col className="w-[56px]" />
<col className="w-[56px]" />
</colgroup>
<thead>
<tr>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
NO
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
관로 ID
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
용도
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
경과년도
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
거리(m)
</th>
</tr>
</thead>
<tbody>
{nearbyPipes.map((pipe, index) => (
<tr
key={`${pipe.fid}-${index}`}
className="cursor-pointer hover:bg-blue-50"
onClick={() => onSelectPipe(pipe)}
>
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900">
{index + 1}
</td>
<td className="border border-gray-300 px-1 py-2 text-left text-[0.95rem] font-semibold text-[#1d4ed8] underline underline-offset-2">
{display(pipe.pipeId)}
</td>
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900 whitespace-nowrap">
{getPipeUseName(pipe)}
</td>
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
{displayElapsedYears(pipe.elapsedYears)}
</td>
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
{displayNumber(pipe.distanceMeters)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
);
}
@@ -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 = void> = T | Promise<T>;
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<SinkholeYearRange>(() =>
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 = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
const settingRows = (
<div className="space-y-1.5 text-[1.05rem] font-medium text-slate-700">
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="sinkhole-radius" className="shrink-0">지반침하반경(m)</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<input
id="sinkhole-radius"
className={numberInputClass}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={radiusText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRadius();
}}
/>
<button
type="button"
className={applyButtonClass}
onClick={() => void commitRadius()}
title="적용"
aria-label="반경 적용"
>
✓
</button>
</div>
</div>
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="sinkhole-start-year" className="shrink-0">년도설정</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<div id="sinkhole-start-year" className="w-[62px]">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={startYearOptions}
value={yearRangeDraft.startYear}
onSelectValue={(value) => updateYearRangeDraft("startYear", String(value))}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
<span className="px-[1px] text-[0.95rem] font-normal text-slate-500">~</span>
<div id="sinkhole-end-year" className="w-[62px]">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={endYearOptions}
value={yearRangeDraft.endYear}
onSelectValue={(value) => updateYearRangeDraft("endYear", String(value))}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
<button
type="button"
className={applyButtonClass}
onClick={() => void commitYearRange()}
title="적용"
aria-label="연도범위 적용"
>
✓
</button>
</div>
</div>
</div>
);
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-2 py-[10px] shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<div className="overflow-visible">{settingRows}</div>
</div>
);
}
return (
<div className="w-full overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-white px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "+" : "-"}
</button>
<span className="text-[1.2rem] font-semibold text-[#d97706]">지반침하정보</span>
</div>
{!isCollapsed && (
<div className="px-2 py-2">
<div className="overflow-visible">{settingRows}</div>
{isAdmin && lastSyncSummary ? (
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-white px-2 py-2 text-[1rem] text-[#7c5b2f]">
<div className="font-semibold">최근 동기화</div>
<div className="mt-1">완료시각: {lastSyncSummary.finishedAt || "-"}</div>
<div className="mt-1">
성공 {lastSyncSummary.successCount}건 / 실패 {lastSyncSummary.failedCount}건
</div>
</div>
) : null}
</div>
)}
</div>
);
}
@@ -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 = <T,>(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<SinkholeFilters>(() => {
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<SinkholeFilters>(() => {
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<SinkholeTableRow[]>([]);
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 (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">지반침하발생현황</h1>
<div />
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[170px_170px_220px_220px_180px_180px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(시작)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) => setFilters({ ...filters, startYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(종료)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) => setFilters({ ...filters, endYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">복구상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.restoreStatus}
onChange={(event) => setFilters({ ...filters, restoreStatus: event.target.value })}
>
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">좌표보정상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.coordStatus}
onChange={(event) => setFilters({ ...filters, coordStatus: event.target.value })}
>
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
<UiTable<SinkholeTableRow>
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}
/>
</section>
</main>
);
}
@@ -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<SinkholeYearRange> | 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<string, string> = {
rowNo: "NO",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
sizeLength: "발생규모연장",
sizeDepth: "발생규모깊이",
detailCause: "상세발생원인",
restoreStatus: "복구상태",
coordStatus: "좌표보정상태",
restoreCompletedYmd: "복구완료일자",
};
export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
sizeLength: "발생규모연장",
sizeDepth: "발생규모깊이",
detailCause: "상세발생원인",
restoreStatus: "복구상태",
restoreCompletedYmd: "복구완료일자",
};
@@ -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<string, unknown>) => {
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<WaterRiskView>("result");
const [snapshot, setSnapshot] = useState<WaterRiskSessionSnapshot>(
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
);
const [draftResultFilters, setDraftResultFilters] = useState<ResultFilters>(
DEFAULT_RESULT_FILTERS,
);
const [appliedResultFilters, setAppliedResultFilters] = useState<ResultFilters>(
DEFAULT_RESULT_FILTERS,
);
const [draftSettingFilters, setDraftSettingFilters] = useState<SettingFilters>(
DEFAULT_SETTING_FILTERS,
);
const [appliedSettingFilters, setAppliedSettingFilters] = useState<SettingFilters>(
DEFAULT_SETTING_FILTERS,
);
const [selectedResultAreaId, setSelectedResultAreaId] = useState<string>(
WATER_RISK_RESULT_ROWS[0]?.areaId ?? "",
);
const [selectedSettingAreaId, setSelectedSettingAreaId] = useState<string>("");
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<string, { areaId: string; areaName: string }>();
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 (
<main className="sn-popup-page">
<header className="sn-popup-header justify-start">
{activeView === "setting" ? (
<button
type="button"
className="sn-popup-header-link"
onClick={handleReturnToResultView}
>
← 결과조회
</button>
) : null}
<h1 className="sn-popup-title">
{activeView === "result" ? "통수위험분석" : "통수위험분석 설정"}
</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
{activeView === "result" ? (
<section className="flex h-full min-h-0 flex-col gap-3">
<div className="sn-popup-filter-panel md:grid-cols-[160px_140px_140px_140px_180px_120px_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">기준일자</div>
<input
type="date"
value={draftResultFilters.analysisDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
analysisDate: event.target.value,
}))
}
aria-label="년월일"
className={TOOLBAR_INPUT_CLASS_NAME}
/>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">기준시간</div>
<input
type="time"
value={draftResultFilters.analysisTime}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
analysisTime: event.target.value,
}))
}
aria-label="시간"
className={TOOLBAR_INPUT_CLASS_NAME}
/>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
value={draftResultFilters.sidoName}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
sidoName: event.target.value,
sggName: "전체",
}))
}
aria-label="시도"
className={TOOLBAR_INPUT_CLASS_NAME}
>
{WATER_RISK_SIDO_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
value={draftResultFilters.sggName}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
sggName: event.target.value,
}))
}
aria-label="시군구"
className={TOOLBAR_INPUT_CLASS_NAME}
>
{resultSggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">중점관리지역</div>
<select
value={draftResultFilters.areaId}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
areaId: event.target.value,
}))
}
aria-label="중점관리지역"
className={TOOLBAR_INPUT_CLASS_NAME}
>
<option value="ALL">중점관리지역</option>
{resultAreaOptions.map((option) => (
<option key={option.areaId} value={option.areaId}>
{option.areaName}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">위험상태</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className={TOOLBAR_INPUT_CLASS_NAME}
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="NORMAL">정상</option>
</select>
</label>
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button"
onClick={handleApplyResultFilters}
>
조회
</Button>
<Button
type="button"
size="sm"
color="white"
className={SECONDARY_BUTTON_CLASS_NAME}
onClick={handleOpenSettingView}
>
설정
</Button>
</div>
</div>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
통수위험 분석 결과 {visibleResultRows.length.toLocaleString("ko-KR")}건
</h3>
</div>
<div className={RESULT_TABLE_CONTAINER_CLASS_NAME}>
<div className="overflow-x-auto overflow-y-hidden">
<table
className="sn-popup-grid-table"
style={{ minWidth: `${resultTableMinWidth}px`, width: "100%" }}
>
<thead>
<tr className="sn-popup-grid-head-row">
<th rowSpan={2} className="sn-popup-grid-head-cell">no</th>
<th rowSpan={2} className="sn-popup-grid-head-cell">시도</th>
<th rowSpan={2} className="sn-popup-grid-head-cell">시군구</th>
<th rowSpan={2} className="sn-popup-grid-head-cell">중점관리지역</th>
<th rowSpan={2} className="sn-popup-grid-head-cell">예측강우량(mm)</th>
{Array.from({ length: maxPipeCount }).map((_, index) => (
<th
key={`pipe-group-${index + 1}`}
colSpan={2}
className="sn-popup-grid-head-cell"
>
관로 ID {index + 1}
</th>
))}
<th rowSpan={2} className="sn-popup-grid-head-cell">통수위험</th>
</tr>
<tr className="sn-popup-grid-head-row">
{Array.from({ length: maxPipeCount }).map((_, index) => (
<Fragment key={`pipe-sub-${index + 1}`}>
<th className="sn-popup-grid-head-cell-sub">유출유량(㎥/hr)</th>
<th className="sn-popup-grid-head-cell-sub">통수량(㎥/hr)</th>
</Fragment>
))}
</tr>
</thead>
<tbody>
{pagedResultRows.map((row, index) => {
const isSelected = row.areaId === selectedResultAreaId;
const rowNumber =
(resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE + index + 1;
return (
<tr
key={row.areaId}
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row sn-popup-grid-row-hover"}`}
onClick={() => handleSelectResultRow(row)}
>
<td className="sn-popup-grid-cell text-center">{rowNumber}</td>
<td className="sn-popup-grid-cell">{row.sidoName}</td>
<td className="sn-popup-grid-cell">{row.sggName}</td>
<td className="sn-popup-grid-cell font-semibold">{row.areaName}</td>
<td className="sn-popup-grid-cell text-right">{row.forecastRainQty.toFixed(1)}</td>
{Array.from({ length: maxPipeCount }).map((_, pipeIndex) => {
const pipe = row.pipeSummaries[pipeIndex];
return (
<Fragment key={`${row.areaId}-pipe-${pipeIndex}`}>
<td className="sn-popup-grid-cell text-right">
{pipe ? pipe.runoffFlowQty.toFixed(1) : ""}
</td>
<td className="sn-popup-grid-cell text-right">
{pipe ? pipe.conveyQty.toFixed(1) : ""}
</td>
</Fragment>
);
})}
<td className="sn-popup-grid-cell text-center">
<span className={`sn-popup-badge min-w-[56px] ${getRiskBadgeClassName(row.riskStatus)}`}>
{buildWaterRiskStatusLabel(row.riskStatus)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{visibleResultRows.length > WATER_RISK_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={visibleResultRows.length}
cur={resultCurrentPage}
pageSize={WATER_RISK_PAGE_SIZE}
onPageChange={setResultCurrentPage}
/>
</div>
) : null}
</section>
) : (
<section className="flex h-full min-h-0 flex-col gap-3">
<div className="sn-popup-filter-panel md:grid-cols-[160px_160px_220px_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
value={draftSettingFilters.sidoName}
onChange={(event) =>
setDraftSettingFilters((current) => ({
...current,
sidoName: event.target.value,
sggName: "전체",
}))
}
aria-label="시도"
className={TOOLBAR_INPUT_CLASS_NAME}
>
{WATER_RISK_SIDO_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
value={draftSettingFilters.sggName}
onChange={(event) =>
setDraftSettingFilters((current) => ({
...current,
sggName: event.target.value,
}))
}
aria-label="시군구"
className={TOOLBAR_INPUT_CLASS_NAME}
>
{settingSggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">중점관리지역</div>
<select
value={draftSettingFilters.areaId}
onChange={(event) =>
setDraftSettingFilters((current) => ({
...current,
areaId: event.target.value,
}))
}
aria-label="중점관리지역"
className={TOOLBAR_INPUT_CLASS_NAME}
>
<option value="ALL">중점관리지역</option>
{settingAreaOptions.map((option) => (
<option key={option.areaId} value={option.areaId}>
{option.areaName}
</option>
))}
</select>
</label>
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button"
onClick={handleApplySettingFilters}
>
조회
</Button>
</div>
</div>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
통수위험 설정 현황 {visibleSettingRows.length.toLocaleString("ko-KR")}건
</h3>
</div>
<div
className={`${SETTING_TABLE_CONTAINER_CLASS_NAME} ${showSettingEditor ? "flex-[0_0_44%]" : ""}`}
>
<div className="overflow-x-auto overflow-y-hidden">
<table
className="sn-popup-grid-table"
style={{ minWidth: `${settingTableMinWidth}px`, width: "100%" }}
>
<thead className="sn-popup-grid-head-row">
<tr>
<th className="sn-popup-grid-head-cell">시도</th>
<th className="sn-popup-grid-head-cell">시군구</th>
<th className="sn-popup-grid-head-cell">중점관리지역</th>
<th className="sn-popup-grid-head-cell">유출관로ID</th>
<th className="sn-popup-grid-head-cell">관경</th>
<th className="sn-popup-grid-head-cell">연장</th>
<th className="sn-popup-grid-head-cell">재질</th>
<th className="sn-popup-grid-head-cell">설정순번</th>
<th className="sn-popup-grid-head-cell">설정완료여부</th>
</tr>
</thead>
<tbody>
{pagedSettingRows.map((row, index) => {
const isSelected =
row.areaId === (selectedSettingAreaId || snapshot.selectedSettingAreaId);
return (
<tr
key={`${row.areaId}-${row.pipeId}`}
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row sn-popup-grid-row-hover"}`}
onClick={() => handleSelectSettingArea(row.areaId)}
>
<td className="sn-popup-grid-cell">{row.sidoName}</td>
<td className="sn-popup-grid-cell">{row.sggName}</td>
<td className="sn-popup-grid-cell font-semibold">{row.areaName}</td>
<td className="sn-popup-grid-cell">{row.pipeId}</td>
<td className="sn-popup-grid-cell">{row.pipeDia}</td>
<td className="sn-popup-grid-cell">{row.pipeLen}</td>
<td className="sn-popup-grid-cell">{row.pipeMaterial}</td>
<td className="sn-popup-grid-cell text-center">{row.setSeq}</td>
<td className="sn-popup-grid-cell text-center">
{row.setCompleteYn === "Y" ? "완료" : "미완료"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{visibleSettingRows.length > WATER_RISK_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={visibleSettingRows.length}
cur={settingCurrentPage}
pageSize={WATER_RISK_PAGE_SIZE}
onPageChange={setSettingCurrentPage}
/>
</div>
) : null}
{showSettingEditor ? (
<div className="sn-popup-card min-h-0 flex-1">
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between border-b border-border bg-secondary px-4 py-3">
<div className="sn-popup-panel-title">
설정 편집 대상: {currentSettingAreaName || "선택 전"}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
color="white"
className="sn-popup-secondary-button-sm"
onClick={handleStopSettingEdit}
>
편집 종료
</Button>
<Button
type="button"
size="sm"
color="white"
className="sn-popup-secondary-button-sm"
onClick={handleClearStagedPipes}
disabled={snapshot.stagedPipes.length === 0}
>
목록 초기화
</Button>
<Button
type="button"
size="sm"
color="primary"
className={ACTION_BUTTON_CLASS_NAME}
onClick={handlePreviewSave}
>
저장 preview
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="sn-popup-grid-table">
<thead className="sn-popup-grid-head-row-strong">
<tr>
<th className="sn-popup-grid-head-cell">순번</th>
<th className="sn-popup-grid-head-cell">유출관로ID</th>
<th className="sn-popup-grid-head-cell">관경</th>
<th className="sn-popup-grid-head-cell">연장</th>
<th className="sn-popup-grid-head-cell">재질</th>
<th className="sn-popup-grid-head-cell">선택원천</th>
<th className="sn-popup-grid-head-cell">조작</th>
</tr>
</thead>
<tbody>
{snapshot.stagedPipes.length === 0 ? (
<tr>
<td
colSpan={7}
className="sn-popup-grid-cell sn-popup-empty-cell px-3 py-6 text-center"
>
staged 유출관로가 없습니다. 지도에서 하수관로를 선택하세요.
</td>
</tr>
) : (
snapshot.stagedPipes.map((pipe, index) => (
<tr
key={pipe.pipeId}
className="sn-popup-grid-row"
>
<td className="sn-popup-grid-cell text-center">{index + 1}</td>
<td className="sn-popup-grid-cell font-semibold">{pipe.pipeId}</td>
<td className="sn-popup-grid-cell">{pipe.pipeDia}</td>
<td className="sn-popup-grid-cell">{pipe.pipeLen}</td>
<td className="sn-popup-grid-cell">{pipe.pipeMaterial}</td>
<td className="sn-popup-grid-cell">{pipe.source}</td>
<td className="sn-popup-grid-cell">
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
color="white"
className="sn-popup-secondary-button-sm"
onClick={() => handleMoveStagedPipe(pipe.pipeId, "UP")}
disabled={index === 0}
>
위
</Button>
<Button
type="button"
size="sm"
color="white"
className="sn-popup-secondary-button-sm"
onClick={() => handleMoveStagedPipe(pipe.pipeId, "DOWN")}
disabled={index === snapshot.stagedPipes.length - 1}
>
아래
</Button>
<Button
type="button"
size="sm"
color="white"
className="sn-popup-danger-button-sm"
onClick={() => handleRemoveStagedPipe(pipe.pipeId)}
>
삭제
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
) : null}
</section>
)}
</section>
</main>
);
}
@@ -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<string, WaterRiskMapPipe[]> = {
"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<string, string[]> = {
전체: ["전체", "강남구", "해운대구"],
서울특별시: ["전체", "강남구"],
부산광역시: ["전체", "해운대구"],
};
export const WATER_RISK_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
export const WATER_RISK_COLOR_LABELS: Record<WaterRiskGridCell["colorLevel"], string> = {
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 ?? "";
};
File diff suppressed because it is too large Load Diff
@@ -34,14 +34,14 @@ const BasicTab: React.FC = () => {
const { basicType, setBasicType } = useSearchModalStore();
return (
<div className="relative border rounded-lg pt-5 px-5 w-full min-h-[10vh]">
<div className="relative w-full min-h-[10vh] rounded-lg border border-border bg-background px-5 pt-5">
{/* 구분 */}
<div className="mb-3 flex items-center justify-between gap-4">
<div className="flex items-center">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구분</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구분</Label>
<SelectBox
className="w-[150px] mr-3"
selectClass="rounded-lg bg-white"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={BASIC_TYPES}
onSelectValue={(value: string | number) =>
@@ -63,4 +63,4 @@ const BasicTab: React.FC = () => {
);
};
export default React.memo(BasicTab);
export default React.memo(BasicTab);
@@ -37,12 +37,12 @@ const OperationTab: React.FC = () => {
}));
return (
<div className="relative flex flex-col border rounded-lg pt-5 px-5 w-full min-h-[10vh]">
<div className="relative flex min-h-[10vh] w-full flex-col rounded-lg border border-border bg-background px-5 pt-5">
<div className="flex items-center mb-3">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">업무구분</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">업무구분</Label>
<SelectBox
className="w-40 mr-8"
selectClass="rounded-lg bg-white text-xl"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={WORK_TYPES.map((w) => ({
val: w.val,
@@ -54,7 +54,7 @@ const OperationTab: React.FC = () => {
/>
</div>
<div className="flex items-center mb-6">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">사업명</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">사업명</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center mr-8"
placeholder="입력"
@@ -74,10 +74,10 @@ const OperationTab: React.FC = () => {
startLabel="수행기간"
endLabel=""
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">사업 유형</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">사업 유형</Label>
<SelectBox
className="w-60 mr-8"
selectClass="rounded-lg bg-white text-xl"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[{ val: "0", text: "전체" }, ...BIZ_TY_CD_SELECT_ARRAY]}
value={var2}
@@ -95,4 +95,4 @@ const OperationTab: React.FC = () => {
)
};
export default React.memo(OperationTab);
export default React.memo(OperationTab);
@@ -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<ReportTabProps> = ({ 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<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
const [showTable, setShowTable] = useState(false);
// const [tableData, setTableData] = useState<any[]>([]);
// 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<ReportTabProps> = ({ 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<ReportTabProps> = ({ 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 (
<>
<div className={`relative flex flex-col border rounded-lg pt-5 px-5 w-full ${isSearch ? "min-h-[10vh]" : "min-h-[6vh]"}`}>
{isSearch && (
<>
<div className="flex items-center mb-3">
<div className="relative mt-4 rounded-lg border px-5 pt-5 pb-3">
<div className="overflow-x-auto">
<div className="flex min-w-max items-center gap-4 whitespace-nowrap">
{isSearch && (
<CtpvSggPrcsFcltSelectBox
className="shrink-0"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId) }}
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId); }}
onSigunguChange={setSggCd}
setIsLoading={setIsLoading}
/>
)}
<YearRangeSelectBox
className="shrink-0 !mr-1"
selectClass="w-[78px] flex-shrink-0 rounded-lg bg-field text-foreground"
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) => setYearFilter({
start: normalizeYear(range.start),
end: normalizeYear(range.end),
})}
startLabel="승인년도"
endLabel=""
/>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={plantZoneOptions}
value={plantZoneGubun}
onSelectValue={(val) => setPlantZoneGubun(String(val))}
selectContentClass="z-[101]"
/>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">보고서 구분</Label>
<SelectBox
className="w-36 shrink-0"
selectClass="rounded-lg bg-field text-foreground"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun}
onSelectValue={(val) => setReportGubun(String(val))}
selectContentClass="z-[101]"
/>
<div className="ml-auto shrink-0 pl-4">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</>
)}
<div className="flex items-center mb-6">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">보고서 구분</Label>
<SelectBox className="w-44 mr-8"
selectClass="rounded-lg bg-white"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun} onSelectValue={val => setReportGubun(String(val))}
selectContentClass="z-[101]" labelClass='whitespace-nowrap text-xl text-gray-600 pr-2'
/>
{/* 승인년도 필터 */}
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
startLabel="승인년도" endLabel=""
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
<div className="h-[79vh] w-full overflow-hidden rounded-lg border">
{/* 테이블2 영역 (가로/세로 스크롤 가능) */}
<div className="flex-1 px-2 pb-2">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
return (<>조회 결과</>);
})()
}
tableData={tableData}
headlist={reportTableHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleReportCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
<div className="mt-3">
<LoadingOverlay isLoading={isLoading} />
<UiTable
className="!m-0"
clickableHeadIds={["fileName"]}
headName={<>조회 결과</>}
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}
/>
</div>
</>
);
};
export default React.memo(ReportTab);
@@ -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<SearchModalProps> = ({ renderMode = "launcher" }) => {
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', autoSearchReady = false }) => {
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
@@ -64,39 +66,38 @@ const SearchModal: React.FC<SearchModalProps> = ({ 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<SearchModalProps> = ({ 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 = () => (
<>
{/* 탭 영역 */}
<UITab
defaultValue={activeTab}
tabList={TABS.map((tab: any) => ({
@@ -159,69 +157,47 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher" }) =>
}))}
onValueChange={(value: string) => setActiveTab(value)}
/>
{activeTab === 'basic' && (<BasicTab />)} {/* 기본정보 탭 */}
{activeTab === 'operation' && (<OperationTab />)} {/* 유지관리 정보 탭 */}
{activeTab === 'report' && (
<div className="mt-4 flex h-[79dvh] w-full items-center justify-center rounded-lg border bg-white p-6">
<div className="flex max-w-[520px] flex-col items-center text-center">
<h2 className="mb-3 text-2xl font-semibold text-gray-900">
하수도정비 기본계획보고서
</h2>
<p className="mb-5 text-lg leading-8 text-gray-600">
보고서 상세는 모달 내부 탭 대신
<br />
전용 윈도우 팝업으로 열리도록 전환되었습니다.
</p>
<Button className="px-6 py-2 text-white" onClick={openReportTabPopup}>
전용 팝업 열기
</Button>
</div>
</div>
)} {/* 하수도정비기본계획보고서 탭 */}
{activeTab === 'basic' && (<BasicTab />)}
{activeTab === 'operation' && (<OperationTab />)}
{activeTab === 'report' && (<ReportTab open={activeTab === 'report'} />)}
{/* 📋 결과 리스트 */}
{/* 기본정보 탭 */}
{activeTab === 'basic' && (
<div className="h-[79dvh] w-full rounded-lg border mt-4 overflow-x-scroll">
<div className="mt-4 h-[79dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isLoading} />
<UiTable className='w-[120%]'
headName={(() => {
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={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={basicExcelDownload}>파일 다운로드</Button>
</div>
}
/>
<UiTable
className='overflow-x-auto [&_table]:min-w-full [&_table]:w-max'
headName={(() => {
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={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={basicExcelDownload}>파일 다운로드</Button>
</div>
}
/>
</div>
)}
{/* 유지관리 정보 탭 */}
{activeTab === 'operation' && (
<>
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border mt-4">
{/* <div className="flex-1 px-2 pb-2"> */}
<div className="mt-4 h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={
(() => {
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<SearchModalProps> = ({ renderMode = "launcher" }) =>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-sm font-medium green" size="sm" onClick={operationExcelDownload}>파일 다운로드</Button>
}
/>
{/* </div> */}
</div>
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
{/* 테이블2 영역 */}
{/* <div className="flex-1 px-2 pb-2"> */}
<LoadingOverlay isLoading={isDetailLoading} />
<UiTable
headName={
(() => {
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<SearchModalProps> = ({ renderMode = "launcher" }) =>
</div>
}
/>
{/* </div> */}
</div>
</>
)}
</>
);
if (renderMode === "launcher") {
if (renderMode === 'launcher') {
return null;
}
return (
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-white p-4'>
<h1 className="mb-3 text-2xl font-semibold text-gray-900">관망도 검색</h1>
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border bg-white p-4">
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-container p-4 text-foreground'>
<h1 className="mb-3 text-2xl font-semibold text-foreground">관망도 검색</h1>
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border border-border bg-background p-4">
{renderBody()}
</div>
<Toaster position="top-center"></Toaster>
<Toaster position="top-center" />
</div>
);
};
export default React.memo(SearchModal);
@@ -97,10 +97,10 @@ const SwageMhl: React.FC = () => {
endLabel=""
/>
{/* 용도 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">용도</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">용도</Label>
<SelectBox
className="w-48 mr-8"
selectClass="rounded-lg bg-white text-xl"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_MHL_USE_CODES]}
value={var1}
@@ -110,7 +110,7 @@ const SwageMhl: React.FC = () => {
selectContentClass="z-[101]"
/>
{/* 하수맨홀 구경 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구경(mm)</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구경(mm)</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
@@ -153,4 +153,4 @@ const SwageMhl: React.FC = () => {
);
};
export default React.memo(SwageMhl);
export default React.memo(SwageMhl);
@@ -102,10 +102,10 @@ const SwagePpl: React.FC = () => {
endLabel=""
/>
{/* 용도 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">용도</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">용도</Label>
<SelectBox
className="w-32 mr-8"
selectClass="rounded-lg bg-white text-xl"
selectClass="rounded-lg bg-field text-xl text-foreground"
label=""
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_PPL_USE_CODES]}
value={var1}
@@ -128,7 +128,7 @@ const SwagePpl: React.FC = () => {
selectContentClass="z-[101]"
/> */}
{/* 관경 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">관경(mm)</Label>
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">관경(mm)</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
@@ -206,4 +206,4 @@ const SwagePpl: React.FC = () => {
);
};
export default React.memo(SwagePpl);
export default React.memo(SwagePpl);
@@ -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<ReportDataFile[]> => {
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
};
}
}
@@ -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<string, string> = {
if (currentTab === 'basic') {
await fetchBasicFilteredData();
} else if (currentTab === 'operation') {
const requestDto: Record<string, string> = {
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 };
};
}
@@ -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<string, WaterDepthLegendItem>();
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({
</div>
</div>
<div className="w-[140px] mx-auto flex flex-col items-center">
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침 수 심</div>
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침수심 범례</div>
<div className="space-y-1 w-full">
<LegendItem color="rgb(255, 255, 127)" label="0.0~0.5m 미만" /> {/* 코드: N330 */}
<LegendItem color="rgb(191, 255, 0)" label="0.5~1.0m 미만" /> {/* 코드: N331 */}
<LegendItem color="rgb(0, 255, 255)" label="1.0~2.0m 미만" /> {/* 코드: N332 */}
<LegendItem color="rgb(191, 127, 255)" label="2.0~5.0m 미만" /> {/* 코드: N333 */}
<LegendItem color="rgb(255, 0, 127)" label="5.0m 이상" /> {/* 코드: N334 */}
{legendItems.map((item) => (
<LegendItem
key={item.key}
color={item.color}
borderColor={item.borderColor}
label={item.label}
/>
))}
</div>
<div className="border-t-2 border-blue-900 mt-1 mx-auto w-full" />
</div>
@@ -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 (
<div className="flex items-center gap-2 w-full">
<span
className="inline-block w-16 h-6"
style={{ backgroundColor: color, border: "1px solid #ccc" }}
style={{ backgroundColor: color, border: `1px solid ${borderColor}` }}
/>
<span className="text-xl text-gray-900 font-bold">{label}</span>
</div>
);
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../map/page";
@@ -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 = ({
/>
</>
)}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
sggName={sggName}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
}}
onSigunguChange={setSggCd}
setSggName={setSggName}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={onSwagePrcsFcltChange}
setSwtrmtFcltNm={setSwtrmtFcltNm}
setIsLoading={setIsLoading}
/>
{/* 침입수 분석 실행처럼 선택 구조가 다른 화면은 커스텀 selector를 주입하고, 나머지는 기존 시설 selector를 유지한다. */}
{selectionComponent ?? (
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
sggName={sggName}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
}}
onSigunguChange={setSggCd}
setSggName={setSggName}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={onSwagePrcsFcltChange}
setSwtrmtFcltNm={setSwtrmtFcltNm}
defaultSwtrmtFcltNm={defaultSwtrmtFcltNm}
setIsLoading={setIsLoading}
/>
)}
{selectBox?.searchChk && (
<div className='flex flex-1 justify-end mr-6'>
<Button size="sm" onClick={() => selectBox.onSearchClick?.()}>조회</Button>
@@ -246,6 +258,15 @@ const BaseLayout = ({
{analysisBtn && (
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={handleConfirm}>{analysisBtn}</Button>
)}
{resultViewBtn && (
<Button
className="px-4 py-2 bg-slate-600 text-white rounded-sm text-sm font-medium hover:bg-slate-700 !mr-[1.2rem]"
size="sm"
onClick={onResultView}
>
{resultViewBtn}
</Button>
)}
</div>
</div>
<Form {...form}>
@@ -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();
};
@@ -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<string>('0'); // 하수처리장코드
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState<string>('0'); // ?????? ?? ??
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
const [ctpvCd, setCtpvCd] = useState<string>('0'); // 시도 코드 상태
const [sggCd, setSggCd] = useState<string>('0'); // 시군구 코드 상태
const [sggName, setSggName] = useState<string>(''); // 시군구 이름 상태
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(''); // 하수처리시설명 상태
const [ctpvCd, setCtpvCd] = useState<string>(DEFAULT_CTPV_CD); // 시도 코드 상태
const [sggCd, setSggCd] = useState<string>(DEFAULT_SGG_CD); // 시군구 코드 상태
const [sggName, setSggName] = useState<string>(DEFAULT_SGG_NAME); // 시군구 이름 상태
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(DEFAULT_SWTRMT_FCLT_NM); // 하수처리시설명 상태
const [uploadedFilesData, setUploadedFilesData] = useState<UploadedFileInfo[]>([
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<z.infer<typeof formSchema>>({
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
export default IrSetting
+55 -18
View File
@@ -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<typeof formSchema>;
// 기본 폼 값 정의 (테스트 편의를 위해 초기값 설정, 추후 삭제 필요)
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<string>('0'); // 하수처리장 (swtrmtFcltCd)
const [ctpvCd, setCtpvCd] = useState<string>('0'); // 시도 코드 상태
const [sggCd, setSggCd] = useState<string>('0'); // 시군구 코드 상태
const [sggName, setSggName] = useState<string>(''); // 시군구 이름 상태
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(''); // 하수처리시설명 상태
const [ctpvCd, setCtpvCd] = useState<string>(DEFAULT_CTPV_CD); // 시도 코드 상태
const [sggCd, setSggCd] = useState<string>(DEFAULT_SGG_CD); // 시군구 코드 상태
const [sggName, setSggName] = useState<string>(DEFAULT_SGG_NAME); // 시군구 이름 상태
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(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<FormSchemaType>({
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}
>
@@ -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<string, unknown>;
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 (
<div className="pointer-events-none fixed bottom-4 left-4 z-[90] w-[190px] max-w-[calc(100vw-2rem)] rounded-lg border border-primary bg-white/95 p-3 shadow-[0_10px_24px_rgba(37,139,232,0.18)] backdrop-blur-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="truncate text-[1.05rem] font-semibold text-primary">
{item.analysisName}
</p>
<span className="text-[1rem] font-semibold text-primary">
{progressPercent}%
</span>
</div>
<div className="mb-2 h-[6px] overflow-hidden rounded-full bg-[#dbeafe]">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="truncate text-[0.95rem] text-slate-600">{progressText}</p>
</div>
);
};
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<PendingAnalysisWatch[]>([]);
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 ? <AnalysisProgressOverlay item={activeOverlayItem} /> : null;
}
+2 -2
View File
@@ -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";
@@ -58,4 +58,4 @@ export default function WithBaseLayout({
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
}
@@ -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<typeof Calendar>;
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={[
"h-[3rem] w-[12rem] justify-start border-input !bg-background px-4 py-2 text-left text-[1.3rem] font-normal text-muted-foreground hover:!bg-accent",
className,
]
.filter(Boolean)
.join(" ")}
disabled={disabled === true}
>
<CalendarIcon className="!size-[1.6rem]" />
{selected ? formatDateLabel(selected) : <span>{placeholder}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
classNames={{ cell: "rounded-md" }}
mode="single"
selected={selected}
onSelect={handleSelect}
disabled={disabled}
defaultMonth={defaultMonth}
/>
</PopoverContent>
</Popover>
);
}
@@ -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 = () => {
<div className='flex items-center gap-5'>
<div className='flex items-center gap-2'>
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
<DatePicker mode={'single'}
<SearchDatePicker
selected={fromDate}
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
modifiers={{
disabled: { after: new Date() }
}}
disabled={{ after: new Date() }}
/>
<span>~</span>
<DatePicker mode={'single'}
<SearchDatePicker
selected={toDate}
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
modifiers={{
disabled: { after: new Date() }
}}
disabled={{ after: new Date() }}
/>
</div>
<div className='flex items-center'>
@@ -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 = () => {
<div className='flex items-center gap-4'>
<div className='flex items-center space-x-2'>
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
<DatePicker mode={'single'}
<SearchDatePicker
selected={fromDate}
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
modifiers={{
disabled: { after: new Date() }
}} />
disabled={{ after: new Date() }} />
<span>~</span>
<DatePicker mode={'single'}
<SearchDatePicker
selected={toDate}
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
modifiers={{
disabled: { after: new Date() }
}} />
disabled={{ after: new Date() }} />
<div className='flex items-center'>
<Label className="w-20 text-xl text-gray-600">등록자</Label>
<Input
@@ -100,19 +100,21 @@ export const validateBasicSelections = (
requireSido?: boolean;
requireSigungu?: boolean;
requireTreatmentPlant?: boolean;
treatmentPlantLabel?: string;
} = {}
): SelectionValidationResult => {
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);
+30 -8
View File
@@ -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;
}
+105 -25
View File
@@ -11,6 +11,7 @@
*/
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";
@@ -30,6 +31,35 @@ 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;
@@ -81,7 +111,22 @@ export const getData = async (url: string): Promise<any> => {
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<any> => {
@@ -103,6 +148,32 @@ export const postData = async (url: string, data: object): Promise<any> => {
}
};
export const postRawJson = async (url: string, data: object): Promise<any> => {
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;
@@ -144,29 +215,34 @@ export const postBlobData = async (
};
export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
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<any> => {
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<any> => {
@@ -213,11 +289,15 @@ export const getRawJson = async (url: string, init: RequestInit = {}): Promise<a
return null;
}
const trimmed = text.trim();
if (trimmed.startsWith("<")) {
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
}
try {
return JSON.parse(text);
} catch (error) {
console.error('JSON parsing error:', error);
throw new Error(`Invalid JSON response from server: ${url}`);
} catch {
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
}
};
@@ -35,10 +35,15 @@ interface CtpvSggPrcsFcltSelectBoxProps {
selectedSwagePrcsFclt?: string;
onSwagePrcsFcltChange?: (value: string) => 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 (
<div className={`flex items-center justify-center ${className} mr-8`}>
@@ -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({
)}
</div>
);
}
}
+55 -19
View File
@@ -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({
{/* 컬럼 제목 */}
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
{head.title}
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
</TableCell>
{/* 데이터 값 또는 입력 필드 */}
<TableCell className="text-xs">
@@ -160,21 +181,28 @@ export function VerticalTable({
head.type === "select" ? (
<select
id={head.id}
value={String(formData[head.id]) ?? "-"}
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
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) => (
<option key={option} value={option}>
{option}
</option>
))}
{head.options?.map((option, optionIndex) => {
const optionValue = getSelectOptionValue(option);
return (
<option
key={`${head.id}-${optionValue}-${optionIndex}`}
value={optionValue}
hidden={isSelectOptionHidden(option)}
>
{getSelectOptionLabel(option)}
</option>
);
})}
</select>
) : head.type === "input" ? (
) : head.type === "input" || head.type === "date" ? (
<div className="w-full">
<input
id={head.id}
type="text"
type={head.type === "date" ? "date" : "text"}
value={String(formData[head.id] ?? "")}
onChange={(e) => {
if (head.isNumber) {
@@ -241,8 +269,9 @@ export function VerticalTable({
<TableRow key={head.id}>
{/* 컬럼 제목 */}
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
{head.title}
</TableCell>
{head.title}
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
</TableCell>
{/* 데이터 값 또는 입력 필드 */}
<TableCell className="text-xs">
{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) => (
<option key={option} value={option}>
{option}
</option>
))}
{head.options?.map((option, optionIndex) => {
const optionValue = getSelectOptionValue(option);
return (
<option
key={`${head.id}-${optionValue}-${optionIndex}`}
value={optionValue}
hidden={isSelectOptionHidden(option)}
>
{getSelectOptionLabel(option)}
</option>
);
})}
</select>
) : head.type === "input" ? (
) : head.type === "input" || head.type === "date" ? (
<div className="w-full">
<input
id={head.id}
type={head.isNumber ? "number" : "text"}
type={head.type === "date" ? "date" : head.isNumber ? "number" : "text"}
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
onChange={(e) => {
if (head.isNumber) {
@@ -333,4 +369,4 @@ export function VerticalTable({
<Toaster position="top-center"></Toaster>
</>
);
}
}
@@ -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");
@@ -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`
+10 -2
View File
@@ -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`에 기록한다. 근거: 이 하네스 운영 문서
- 확인됨: 기능 검증 계획은 기능별 `통합검증계획` 문서에 누적한다. 근거: `014-문서관리정책.md`
- 확인됨: 기능 검증 결과는 기능별 `통합검증결과` 문서에 누적한다. 근거: `014-문서관리정책.md`
- 확인됨: 외부 범위 오류나 공통 차단 이슈는 기능 검증결과에 요약하고, 여러 채팅방에 영향을 주는 경우 공통 결정로그 또는 공통 작업내용 문서에 함께 남긴다. 근거: `015-하네스엔지니어링정책.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`를 함께 참조한다.
@@ -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. 예외 처리
- 사용자 요청으로 개별 문서 분리가 명시된 경우
- 외부 전달용 산출물이라 통합 문서 내부 헤더로 대체할 수 없는 경우
- 법적/감사/계약상 독립 파일 보관이 필요한 경우
예외 상황에서도 먼저 통합 문서에 “왜 별도 파일이 필요한지”를 기록한다.
@@ -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`를 기능마다 별도 생성하지 않는다.
- 관측 포인트 없이 `수동 확인 필요`만 적고 끝내지 않는다.
- 인증 실패, 환경 실패, 자동화 한계를 모두 기능 결함으로 뭉뚱그려 기록하지 않는다.
@@ -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`에서 수동 확인한다.
@@ -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`는 통과했다.
- 로그인 세션이 필요한 실제 탭 누적·교체·복귀 상호작용은 수동 검증이 남아 있다.
@@ -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. 판단
- 프론트 수정 범위 내에서 신규 타입 오류를 만들지는 않았다.
- 실제 장애 해소 여부의 최종 판정은 백엔드 반영본과 함께 브라우저 재조회 확인이 필요하다.
@@ -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 연동 후 실제 저장/다운로드
@@ -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 응답 구조 준비
- 인증 가능한 브라우저 환경에서 지도 조작 수동 검증 가능
@@ -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 기준으로 먼저 수행한다.
@@ -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 구현과 문서 선행 정리는 완료됐다.
- 최종 상태는 사용자 로컬 수동 검증 결과를 반영한 뒤 `검증 통과` 또는 `추가 보완 필요`로 갱신한다.
@@ -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`) 상태에서 표 하단 공백 체감 확인
- 필요 시 문서만 현재 운영값 기준으로 재동기화
@@ -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 구현 완료
- 브라우저 정밀 조작 검증은 일부 미실행
@@ -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 기준에 머문다.
- 실제 파일 선택, 업로드 버튼 클릭, 행 선택 상호작용은 브라우저 수동 확인이 한 번 더 필요하다.
- 저장소 기존 타입 오류 때문에 이번 기능만 분리한 정적 통과 여부를 보장하기 어렵다.
- 시설구분 목록은 아직 실제 시설물 레이어 조회 결과와 자동 동기화되지 않는다.
@@ -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 편집을 다시 켜면 기존 변경분이 인라인 세션에 복원되는지 확인한다.
@@ -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 편집 진입 시 기존 변경분과 인라인 세션이 복원되는 것을 확인했다.
- 결론: 추가 회귀 보정까지 사용자 수동 검증 완료.

Some files were not shown because too many files have changed in this diff Show More