Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy
Deploy Frontend / deploy (push) Successful in 10m10s
Deploy Frontend / deploy (push) Successful in 10m10s
This commit is contained in:
@@ -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,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@common/assets"],
|
||||
};
|
||||
|
||||
@@ -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: "/",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,7 +18,7 @@ const nextConfig: NextConfig = {
|
||||
fetches: {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
+7
-7
@@ -6,18 +6,18 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://localhost:3015
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
# NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
|
||||
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
# NEXT_PUBLIC_GATEWAY_URL=http://localhost:8883 # local
|
||||
SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
# GATEWAY_URL=http://localhost:8883 # local
|
||||
CONTENTS_INTRO = 31
|
||||
CONTENTS_EXAMPLE = 32
|
||||
|
||||
|
||||
+8
-11
@@ -6,20 +6,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
#NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
#NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
|
||||
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
|
||||
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
|
||||
SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
|
||||
GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
|
||||
CONTENTS_INTRO = 31
|
||||
CONTENTS_EXAMPLE = 32
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
#NEXT_PUBLIC_PROXY_HOST = https://devreuse.hasuon.or.kr
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
|
||||
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
|
||||
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
|
||||
WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
|
||||
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
|
||||
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
|
||||
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
|
||||
NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
|
||||
GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
CONTENTS_INTRO = 24
|
||||
CONTENTS_EXAMPLE = 25
|
||||
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "DM";
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_DM_URL = process.env.NEXT_PUBLIC_DM_SERVER_API_URL ||'http://con.mind-one.co.kr:11035/api/v1';
|
||||
export const SERVER_API_FO_URL = process.env.NEXT_PUBLIC_FO_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
|
||||
export const SERVER_API_WR_URL = process.env.NEXT_PUBLIC_WR_SERVER_API_URL||' http://con.mind-one.co.kr:11080/api/v1';
|
||||
export const SERVER_API_CS_URL = process.env.NEXT_PUBLIC_CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
|
||||
export const SERVER_API_FO_URL = process.env.FO_SERVER_API_URL||' http://con.mind-one.co.kr:11082/api/v1';
|
||||
export const SERVER_API_WR_URL = process.env.WR_SERVER_API_URL||' http://con.mind-one.co.kr:11080/api/v1';
|
||||
export const SERVER_API_CS_URL = process.env.CS_SERVER_API_URL||'http://dev.onasys.co.kr:8000';
|
||||
export const SERVER_API_CS_TUS_URL = process.env.NEXT_PUBLIC_CS_TUS_UPLOAD ||'/attach-service/api/v1/tus/upload'
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
export const SERVER_SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL;
|
||||
export const SERVER_SEWERAGE_API_URL = process.env.SEWERAGE_API_URL;
|
||||
+6
-5
@@ -7,13 +7,14 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/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
|
||||
|
||||
@@ -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 = /
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -11,9 +11,9 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SEWERAGE_API_URL = http://localhost:8883
|
||||
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
|
||||
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
|
||||
|
||||
@@ -6,11 +6,11 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
|
||||
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
|
||||
@@ -8,10 +8,11 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
|
||||
#NEXT_PUBLIC_PROXY_HOST = https://devlion.hasuon.or.kr
|
||||
#NEXT_PUBLIC_SUB_DOMAIN = http://front-sn-feature.iipbs-web.svc.cluster.local:13000
|
||||
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
|
||||
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
|
||||
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
|
||||
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
|
||||
NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x}
|
||||
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
DC_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=.hasuon.or.kr
|
||||
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgedPipeFilters } from "./agedPipeUtils";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
type Props = {
|
||||
filters: AgedPipeFilters;
|
||||
onFiltersChange: (filters: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => MaybePromise;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function AgedPipeLayerSettingPanel({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onApply,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
|
||||
@@ -21,17 +25,74 @@ export default function AgedPipeLayerSettingPanel({
|
||||
setElapsedYearsText(String(filters.elapsedYears ?? ""));
|
||||
}, [filters.elapsedYears]);
|
||||
|
||||
const commitElapsedYears = () => {
|
||||
const commitElapsedYears = async () => {
|
||||
const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
|
||||
if (!validateElapsedYears(elapsedYears)) {
|
||||
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFilters = {
|
||||
...filters,
|
||||
elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText),
|
||||
elapsedYears,
|
||||
};
|
||||
onFiltersChange(nextFilters);
|
||||
onApply(nextFilters);
|
||||
await onApply(nextFilters);
|
||||
alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`);
|
||||
};
|
||||
|
||||
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
|
||||
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
|
||||
|
||||
const settingRow = (
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-slate-700">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className={numberInputClass}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={elapsedYearsText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={applyButtonClass}
|
||||
onClick={() => void commitElapsedYears()}
|
||||
title="적용"
|
||||
aria-label="경과년도 적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
{settingRow}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
|
||||
<button
|
||||
type="button"
|
||||
@@ -39,41 +100,14 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "›" : "⌄"}
|
||||
{isCollapsed ? "+" : "-"}
|
||||
</button>
|
||||
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={elapsedYearsText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
|
||||
onClick={commitElapsedYears}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
{settingRow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -196,17 +196,25 @@ export default function AgedPipePopupPage() {
|
||||
window.close();
|
||||
};
|
||||
|
||||
const getCellClassName = (_row: AgedPipeRow, headId: string) => {
|
||||
if (headId === "mqltCd") {
|
||||
return "break-all whitespace-normal";
|
||||
}
|
||||
|
||||
return "whitespace-normal";
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">노후관로 현황</h1>
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">노후관로 현황</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="xs"
|
||||
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
|
||||
className="sn-popup-header-button"
|
||||
onClick={handleMinimize}
|
||||
>
|
||||
최소화
|
||||
@@ -215,17 +223,17 @@ export default function AgedPipePopupPage() {
|
||||
</header>
|
||||
|
||||
{downloadProgressText ? (
|
||||
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
|
||||
<div className="sn-popup-success-bar">
|
||||
{downloadProgressText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
|
||||
<label className="block md:max-w-[160px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년도</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">경과년도</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -244,10 +252,10 @@ export default function AgedPipePopupPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[320px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
@@ -266,10 +274,10 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[320px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
>
|
||||
@@ -282,12 +290,12 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<div className="sn-popup-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
@@ -295,8 +303,8 @@ export default function AgedPipePopupPage() {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
다운로드
|
||||
@@ -304,19 +312,65 @@ export default function AgedPipePopupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiTable<AgedPipeRow>
|
||||
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
pageSize={10}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
/>
|
||||
<div className="aged-pipe-popup-table min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<AgedPipeRow>
|
||||
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
pageSize={10}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
cellClassName={getCellClassName}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
className="!m-0 overflow-x-hidden"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<style jsx global>{`
|
||||
.aged-pipe-popup-table .card-header {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .card-header h3 {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .card {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui th {
|
||||
padding: 0.8rem 0.45rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui td {
|
||||
padding: 0.85rem 0.4rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(2),
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(3),
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(8) {
|
||||
word-break: keep-all;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,9 +59,9 @@ export const AGED_PIPE_LIST_HEADLIST = [
|
||||
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
|
||||
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
|
||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 14 },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 12 },
|
||||
{ id: "mqltCd", title: "재질", widthPercent: 18 },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
|
||||
];
|
||||
|
||||
export const AGED_PIPE_EXCEL_FIELDS = [
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import {
|
||||
ChartColumnBig,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
Search,
|
||||
@@ -16,36 +15,95 @@ import {
|
||||
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
|
||||
|
||||
type Props = {
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => void;
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
||||
onOpenInfoSearch: () => void;
|
||||
onToggleGisEdit?: () => void;
|
||||
isGisEditActive?: boolean;
|
||||
isInfoSearchActive?: boolean;
|
||||
initialActiveKey?: WindowPopupKey | null;
|
||||
};
|
||||
|
||||
type MenuIconProps = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-primary" : "text-label"}`}
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M12 2.8C9.5 6.5 6.7 9.8 6.7 13.3A5.3 5.3 0 0 0 12 18.6a5.3 5.3 0 0 0 5.3-5.3c0-3.5-2.8-6.8-5.3-10.5Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 13.4c0-1.2.8-2.6 1.9-4.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
key: WindowPopupKey | "infoSearch";
|
||||
label: string;
|
||||
icon: typeof ChartColumnBig;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
renderIcon?: (props: MenuIconProps) => ReactNode;
|
||||
}> = [
|
||||
{ key: "infoSearch", label: "정보검색", icon: Search },
|
||||
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
|
||||
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
|
||||
{ key: "floodHistoryPopup", label: "침수발생이력", icon: History },
|
||||
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
|
||||
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
|
||||
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
|
||||
{ key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon },
|
||||
{ key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig },
|
||||
];
|
||||
|
||||
export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) {
|
||||
export default function AnalysisPopupMenu({
|
||||
onOpenPopup,
|
||||
onOpenInfoSearch,
|
||||
onToggleGisEdit,
|
||||
isGisEditActive = false,
|
||||
isInfoSearchActive = false,
|
||||
initialActiveKey = null,
|
||||
}: Props) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
|
||||
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(initialActiveKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGisEditActive && activeKey === "gisEditPopup") {
|
||||
setActiveKey(null);
|
||||
}
|
||||
}, [activeKey, isGisEditActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInfoSearchActive && activeKey === "infoSearch") {
|
||||
setActiveKey(null);
|
||||
}
|
||||
}, [activeKey, isInfoSearchActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialActiveKey) return;
|
||||
setActiveKey((currentKey) => currentKey ?? initialActiveKey);
|
||||
}, [initialActiveKey]);
|
||||
|
||||
return (
|
||||
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
|
||||
<div className="relative">
|
||||
{isOpen ? (
|
||||
<div className="absolute right-[28px] w-[132px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
|
||||
<div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]">
|
||||
<div className="absolute right-[28px] w-[154px] overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
|
||||
<div className="flex h-[36px] items-center gap-2 border-b border-border bg-secondary px-[10px] text-[13px] font-bold text-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" />
|
||||
<PanelsTopLeft className="h-4 w-4 text-primary" />
|
||||
<span>분석도구</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +111,9 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
<div className="flex flex-col gap-1 p-[6px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeKey === item.key;
|
||||
const isActive = item.key === "gisEditPopup"
|
||||
? isGisEditActive
|
||||
: activeKey === item.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -61,10 +121,16 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
type="button"
|
||||
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
|
||||
isActive
|
||||
? "border-[#b9dcff] bg-[#eaf4ff] font-bold text-[#0f5fa8] before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-[#1e88e5]"
|
||||
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
|
||||
? "border-primary bg-secondary font-bold text-primary before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
|
||||
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (item.key === "gisEditPopup" && onToggleGisEdit) {
|
||||
setActiveKey(item.key);
|
||||
onToggleGisEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveKey(item.key);
|
||||
|
||||
if (item.key === "infoSearch") {
|
||||
@@ -72,15 +138,30 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenPopup(item.key);
|
||||
const popupWindow = onOpenPopup(item.key);
|
||||
if (!popupWindow) {
|
||||
setActiveKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const closeWatcher = window.setInterval(() => {
|
||||
if (!popupWindow.closed) return;
|
||||
window.clearInterval(closeWatcher);
|
||||
setActiveKey((currentKey) =>
|
||||
currentKey === item.key ? null : currentKey,
|
||||
);
|
||||
}, 300);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={`h-[15px] w-[15px] shrink-0 ${
|
||||
isActive ? "text-[#1e88e5]" : "text-[#64748b]"
|
||||
}`}
|
||||
/>
|
||||
<span className="break-keep whitespace-normal">{item.label}</span>
|
||||
{item.renderIcon ? item.renderIcon({ isActive }) : null}
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={`h-[15px] w-[15px] shrink-0 ${
|
||||
isActive ? "text-primary" : "text-label"
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -90,7 +171,7 @@ export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Pro
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#64748b] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] hover:text-[#1e88e5]"
|
||||
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-border bg-background text-label shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-accent hover:text-primary"
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
|
||||
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// 하수관로
|
||||
export const MsSwagePplCol = [
|
||||
{ id: 'fclt_mng_no', title: '시설관리번호' },
|
||||
{ id: 'mngInstNm', title: '관리기관' },
|
||||
{ id: 'inst_mng_no', title: '기관관리번호' },
|
||||
{ id: 'flowDirectionBasis', title: '유하방향 근거' },
|
||||
{ id: 'instl_ymd', title: '설치일자' },
|
||||
{ id: 'elapsedYears', title: '경과년도' },
|
||||
{ id: 'mapsht_no', title: '도엽번호' },
|
||||
@@ -33,6 +35,7 @@ export const MsSwagePplCol2 = [
|
||||
{ id: 'rowNumber', title: '일련\n번호' },
|
||||
{ id: 'fcltMngNo', title: '시설\n관리번호' },
|
||||
{ id: 'instMngNo', title: '기관\n관리번호' },
|
||||
{ id: 'flowDirectionBasis', title: '유하방향\n근거' },
|
||||
{ id: 'ctpvNm', title: '시도' },
|
||||
{ id: 'sggNm', title: '시군구' },
|
||||
{ id: "swtrmt_zone_nm", title: "처리구역" },
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes";
|
||||
|
||||
type Props = {
|
||||
detail: FloodHistoryItem;
|
||||
onClose: () => void;
|
||||
position: {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DetailTab = "year" | "disaster" | "rainfall";
|
||||
|
||||
const renderRows = (rows: Array<[string, string]>) => (
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[42%]" />
|
||||
<col className="w-[58%]" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{rows.map(([label, value], index) => (
|
||||
<tr key={`${label}-${index}`}>
|
||||
<th className="border border-[#d8e3ee] bg-[#f8fbfe] px-3 py-[7px] text-left text-[1.02rem] font-semibold text-[#36506b]">
|
||||
{label}
|
||||
</th>
|
||||
<td className="border border-[#d8e3ee] bg-white px-3 py-[7px] text-[1.02rem] text-[#1f2937]">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) {
|
||||
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("year");
|
||||
|
||||
const yearRows = useMemo(
|
||||
() => [
|
||||
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수년도", `${detail.floodYear}년`],
|
||||
["등급", detail.gradeLabel],
|
||||
["침수구역", detail.districtName],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const disasterRows = useMemo(
|
||||
() => [
|
||||
["침수재해명", detail.disasterName],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수원인상세명", detail.causeDetailName],
|
||||
["침수시작년월일", detail.startDate],
|
||||
["침수종료년월일", detail.endDate],
|
||||
["침수시작시간", detail.startTime],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const rainfallRows = useMemo(
|
||||
() => [
|
||||
["침수시작일자", detail.startDate],
|
||||
["침수종료일자", detail.endDate],
|
||||
["침수시작시간", detail.startTime],
|
||||
["침수종료시간", detail.endTime],
|
||||
["침수시간", detail.floodHour],
|
||||
["강우시작시간", detail.rainStartTime],
|
||||
["강우종료시간", detail.rainEndTime],
|
||||
["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`],
|
||||
["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`],
|
||||
["침수시간", detail.floodHour],
|
||||
["침수원인상세코드", detail.causeDetailCode],
|
||||
["피해금액", detail.damageAmount],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const currentRows = activeTab === "year"
|
||||
? yearRows
|
||||
: activeTab === "disaster"
|
||||
? disasterRows
|
||||
: rainfallRows;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-[#cfdbe8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.18)]"
|
||||
style={{
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[28px] items-center justify-between border-b border-[#d8e3ee] bg-[linear-gradient(180deg,#ffffff_0%,#f6faff_100%)] px-[10px]">
|
||||
<div className="text-[10px] font-bold tracking-[-0.01em] text-[#243b53]">상세정보</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[4px] px-[4px] text-[13px] leading-none text-[#7b8ba1] transition hover:bg-[#eef5fb] hover:text-[#35597d]"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[#d8e3ee] bg-[#f8fbfe] px-[6px] pt-[6px]">
|
||||
<div className="flex gap-[4px]">
|
||||
{[
|
||||
["year", "년도탭"],
|
||||
["disaster", "재해명탭"],
|
||||
["rainfall", "침수발생강우탭"],
|
||||
].map(([tabId, label]) => {
|
||||
const isActive = activeTab === tabId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tabId}
|
||||
type="button"
|
||||
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
|
||||
isActive
|
||||
? "border-[#b8d4f0] border-b-white bg-white text-[#1f5d99]"
|
||||
: "border-[#d8e3ee] bg-[#f3f7fb] text-[#5f7388] hover:bg-[#eef5fb]"
|
||||
}`}
|
||||
onClick={() => setActiveTab(tabId as DetailTab)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-[6px]">
|
||||
<div className="overflow-hidden rounded-[6px] border border-[#d8e3ee] bg-white">
|
||||
{renderRows(currentRows)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import useStore from "@/store/useStore";
|
||||
import {
|
||||
buildFloodHistoryYearOptions,
|
||||
buildFloodHistoryTableRows,
|
||||
FLOOD_HISTORY_DEFAULT_FILTERS,
|
||||
FLOOD_HISTORY_DOWNLOAD_HEADERS,
|
||||
FLOOD_HISTORY_ITEMS,
|
||||
FLOOD_HISTORY_PAGE_SIZE,
|
||||
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
formatFloodArea,
|
||||
formatFloodDepth,
|
||||
normalizeFloodHistoryFilters,
|
||||
type FloodHistoryFilters,
|
||||
type FloodHistoryItem,
|
||||
} from "./floodHistoryTypes";
|
||||
|
||||
const resolveOptionText = (
|
||||
code: string,
|
||||
options: Array<{ text: string; val: string | number }>,
|
||||
prefixLength?: number,
|
||||
) => {
|
||||
const normalized = String(code ?? "").trim();
|
||||
if (!normalized || normalized === "0") return "";
|
||||
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
|
||||
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
|
||||
};
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ id: "rowNo", title: "NO", width: "64px", align: "center" },
|
||||
{ id: "ctpvNm", title: "시도", width: "90px", align: "center" },
|
||||
{ id: "sggNm", title: "시군구", width: "100px", align: "center" },
|
||||
{ id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" },
|
||||
{ id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" },
|
||||
{ id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" },
|
||||
{ id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" },
|
||||
{ id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" },
|
||||
{ id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" },
|
||||
{ id: "disasterName", title: "침수재해명", width: "190px", align: "left" },
|
||||
{ id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" },
|
||||
{ id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" },
|
||||
{ id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" },
|
||||
{ id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" },
|
||||
{ id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" },
|
||||
{ id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" },
|
||||
] as const;
|
||||
|
||||
const buildDownloadRows = (rows: FloodHistoryItem[]) =>
|
||||
rows.map((row, index) => ({
|
||||
rowNo: index + 1,
|
||||
ctpvNm: row.ctpvNm,
|
||||
sggNm: row.sggNm,
|
||||
disasterName: row.disasterName,
|
||||
exmnYearDisplay: `${row.exmnYear}년`,
|
||||
avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(row.floodAreaHa),
|
||||
causeDetailDisplay: row.causeDetailName,
|
||||
startDateDisplay: row.startDate,
|
||||
endDateDisplay: row.endDate,
|
||||
startTimeDisplay: row.startTime,
|
||||
endTimeDisplay: row.endTime,
|
||||
causeCodeDisplay: row.causeDetailCode,
|
||||
districtNameDisplay: row.districtName,
|
||||
damageAmountDisplay: row.damageAmount,
|
||||
floodHourDisplay: row.floodHour,
|
||||
floodYearDisplay: `${row.floodYear}년`,
|
||||
}));
|
||||
|
||||
export default function FloodHistoryPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
// 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다.
|
||||
const [filters, setFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
|
||||
// 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다.
|
||||
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedHistoryId, setSelectedHistoryId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const floodYearOptions = useMemo(
|
||||
() => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS),
|
||||
[],
|
||||
);
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
|
||||
[filters.ctpvCd, sgg],
|
||||
);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalized = normalizeFloodHistoryFilters(appliedFilters);
|
||||
const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2);
|
||||
const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5);
|
||||
const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR");
|
||||
|
||||
return FLOOD_HISTORY_ITEMS.filter((item) => {
|
||||
if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false;
|
||||
if (selectedSggName && item.sggNm !== selectedSggName) return false;
|
||||
if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false;
|
||||
if (
|
||||
disasterToken &&
|
||||
!item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) &&
|
||||
!item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((left, right) => {
|
||||
if (left.floodYear !== right.floodYear) {
|
||||
return Number(right.floodYear) - Number(left.floodYear);
|
||||
}
|
||||
return left.disasterName.localeCompare(right.disasterName, "ko");
|
||||
});
|
||||
}, [appliedFilters, ctpv, sgg]);
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE;
|
||||
const end = start + FLOOD_HISTORY_PAGE_SIZE;
|
||||
return buildFloodHistoryTableRows(
|
||||
filteredRows.slice(start, end),
|
||||
currentPage,
|
||||
FLOOD_HISTORY_PAGE_SIZE,
|
||||
);
|
||||
}, [currentPage, filteredRows]);
|
||||
|
||||
const handleSearch = () => {
|
||||
const normalized = normalizeFloodHistoryFilters(filters);
|
||||
|
||||
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
|
||||
alert("시도를 먼저 선택하세요.");
|
||||
return;
|
||||
}
|
||||
if (normalized.floodYear && normalized.floodYear.length !== 4) {
|
||||
alert("침수발생년도는 4자리 연도로 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAppliedFilters(normalized);
|
||||
setCurrentPage(1);
|
||||
setSelectedHistoryId("");
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (filteredRows.length === 0) {
|
||||
alert("다운로드할 침수발생이력 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(filteredRows) as unknown as JSON[],
|
||||
fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
sheetNameArray: ["침수흔적도"],
|
||||
headerRange: "A1:Q1",
|
||||
headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (row: FloodHistoryItem) => {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
openDetail: false,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: FloodHistoryItem) => {
|
||||
setSelectedHistoryId(row.historyId);
|
||||
// popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다.
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
openDetail: false,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">침수흔적도 목록</h1>
|
||||
<div />
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section">
|
||||
<div className="sn-popup-filter-panel-compact">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpv.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggCd: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">침수발생년도</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.floodYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
floodYear: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{floodYearOptions.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}년
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">침수재해명</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={filters.disasterName}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
disasterName: event.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions md:pr-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="card !m-[1.2rem] !mt-0 min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
||||
<Table variant="secondary" className="w-max min-w-full">
|
||||
<colgroup>
|
||||
{GRID_COLUMNS.map((head) => (
|
||||
<col key={head.id} style={{ width: head.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader className="font-bold">
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">NO</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">시도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">시군구</TableHead>
|
||||
<TableHead colSpan={4} className="whitespace-nowrap">강우 분석정보</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수재해명</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수년도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수시간</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수지구명</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세코드</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">평균침수위</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수면적</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">조사년도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">피해금액</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="whitespace-nowrap">침수시작일자</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수종료일자</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수시작시간</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수종료시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{pagedRows.length > 0 ? (
|
||||
pagedRows.map((row) => {
|
||||
const isSelected = selectedHistoryId === row.historyId;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.historyId}
|
||||
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row-hover"}`}
|
||||
onClick={() => handleRowClick(row)}
|
||||
onDoubleClick={() => handleRowDoubleClick(row)}
|
||||
>
|
||||
<TableCell>{row.rowNo}</TableCell>
|
||||
<TableCell>{row.ctpvNm}</TableCell>
|
||||
<TableCell>{row.sggNm}</TableCell>
|
||||
<TableCell>{row.startDateDisplay}</TableCell>
|
||||
<TableCell>{row.endDateDisplay}</TableCell>
|
||||
<TableCell>{row.startTimeDisplay}</TableCell>
|
||||
<TableCell>{row.endTimeDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.disasterName}</TableCell>
|
||||
<TableCell>{row.floodYearDisplay}</TableCell>
|
||||
<TableCell>{row.floodHourDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.districtNameDisplay}</TableCell>
|
||||
<TableCell>{row.causeCodeDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.causeDetailDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.avgFloodDepthDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.floodAreaDisplay}</TableCell>
|
||||
<TableCell>{row.exmnYearDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.damageAmountDisplay}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={GRID_COLUMNS.length} className="sn-popup-empty-cell h-24 text-center">
|
||||
조회된 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredRows.length > 0 ? (
|
||||
<UiPageNations
|
||||
total={filteredRows.length}
|
||||
cur={currentPage}
|
||||
pageSize={FLOOD_HISTORY_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="sn-popup-help-text">
|
||||
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup";
|
||||
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
|
||||
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLOOD_HISTORY_PAGE_SIZE = 10;
|
||||
|
||||
export type FloodHistoryFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
floodYear: string;
|
||||
disasterName: string;
|
||||
};
|
||||
|
||||
export type FloodHistoryDepthZone = {
|
||||
zoneId: string;
|
||||
depthLabel: string;
|
||||
color: string;
|
||||
ring: [number, number][];
|
||||
};
|
||||
|
||||
export type FloodHistoryItem = {
|
||||
historyId: string;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
floodYear: string;
|
||||
exmnYear: string;
|
||||
disasterName: string;
|
||||
districtName: string;
|
||||
floodAreaHa: number;
|
||||
avgFloodDepthM: number;
|
||||
floodDepthM: number;
|
||||
gradeLabel: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
floodHour: string;
|
||||
causeDetailCode: string;
|
||||
causeDetailName: string;
|
||||
damageAmount: string;
|
||||
rainStartTime: string;
|
||||
rainEndTime: string;
|
||||
totalRainfallMm: number;
|
||||
rainfallIntensityMmHr: number;
|
||||
areaRing: [number, number][];
|
||||
depthZones: FloodHistoryDepthZone[];
|
||||
};
|
||||
|
||||
export type FloodHistoryTableRow = FloodHistoryItem & {
|
||||
rowNo: number;
|
||||
exmnYearDisplay: string;
|
||||
avgFloodDepthDisplay: string;
|
||||
floodAreaDisplay: string;
|
||||
causeDetailDisplay: string;
|
||||
startDateDisplay: string;
|
||||
endDateDisplay: string;
|
||||
startTimeDisplay: string;
|
||||
endTimeDisplay: string;
|
||||
causeCodeDisplay: string;
|
||||
districtNameDisplay: string;
|
||||
damageAmountDisplay: string;
|
||||
floodHourDisplay: string;
|
||||
floodYearDisplay: string;
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
floodYear: "",
|
||||
disasterName: "",
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
disasterName: "침수재해명",
|
||||
exmnYearDisplay: "조사년도",
|
||||
avgFloodDepthDisplay: "평균침수위",
|
||||
floodAreaDisplay: "침수면적",
|
||||
causeDetailDisplay: "침수원인상세",
|
||||
startDateDisplay: "침수시작일자",
|
||||
endDateDisplay: "침수종료일자",
|
||||
startTimeDisplay: "침수시작시간",
|
||||
endTimeDisplay: "침수종료시간",
|
||||
causeCodeDisplay: "침수원인상세코드",
|
||||
districtNameDisplay: "침수지구명",
|
||||
damageAmountDisplay: "피해금액",
|
||||
floodHourDisplay: "침수시간",
|
||||
floodYearDisplay: "침수년도",
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [
|
||||
{ color: "#fff27a", label: "0.0~0.5m" },
|
||||
{ color: "#c2f13d", label: "0.5~1.0m" },
|
||||
{ color: "#67d8ff", label: "1.0~2.0m" },
|
||||
{ color: "#8f8bff", label: "2.0~5.0m" },
|
||||
{ color: "#f2528b", label: "5.0m 이상" },
|
||||
] as const;
|
||||
|
||||
const toRadian = (degree: number) => (degree * Math.PI) / 180;
|
||||
|
||||
const createIrregularRing = ({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX,
|
||||
radiusY,
|
||||
scales,
|
||||
rotationDeg = 0,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
scales: number[];
|
||||
rotationDeg?: number;
|
||||
}): [number, number][] => {
|
||||
const rotation = toRadian(rotationDeg);
|
||||
const points = scales.map((scale, index) => {
|
||||
const angle = rotation + (Math.PI * 2 * index) / scales.length;
|
||||
const jitterX = radiusX * scale * Math.cos(angle);
|
||||
const jitterY = radiusY * scale * Math.sin(angle);
|
||||
return [centerX + jitterX, centerY + jitterY] as [number, number];
|
||||
});
|
||||
|
||||
return [...points, points[0]];
|
||||
};
|
||||
|
||||
const createDepthZones = (
|
||||
historyId: string,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): FloodHistoryDepthZone[] => {
|
||||
return [
|
||||
{
|
||||
zoneId: `${historyId}-depth-1`,
|
||||
depthLabel: "0.0~0.5m",
|
||||
color: "#fff27a",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 5200,
|
||||
centerY: centerY - 2600,
|
||||
radiusX: 3600,
|
||||
radiusY: 2900,
|
||||
rotationDeg: -18,
|
||||
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-2`,
|
||||
depthLabel: "0.5~1.0m",
|
||||
color: "#c2f13d",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 2600,
|
||||
centerY: centerY - 1200,
|
||||
radiusX: 4300,
|
||||
radiusY: 3600,
|
||||
rotationDeg: 12,
|
||||
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-3`,
|
||||
depthLabel: "1.0~2.0m",
|
||||
color: "#67d8ff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 600,
|
||||
centerY: centerY + 2600,
|
||||
radiusX: 5600,
|
||||
radiusY: 4700,
|
||||
rotationDeg: 8,
|
||||
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-4`,
|
||||
depthLabel: "2.0~5.0m",
|
||||
color: "#8f8bff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 4200,
|
||||
centerY: centerY + 5200,
|
||||
radiusX: 3200,
|
||||
radiusY: 2500,
|
||||
rotationDeg: 26,
|
||||
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-5`,
|
||||
depthLabel: "5.0m 이상",
|
||||
color: "#f2528b",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 600,
|
||||
centerY: centerY - 5200,
|
||||
radiusX: 1500,
|
||||
radiusY: 1350,
|
||||
rotationDeg: -10,
|
||||
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const createHistory = (
|
||||
item: Omit<FloodHistoryItem, "areaRing" | "depthZones"> & {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
): FloodHistoryItem => {
|
||||
const { centerX, centerY, width, height, ...detail } = item;
|
||||
|
||||
return {
|
||||
...detail,
|
||||
areaRing: createIrregularRing({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX: width / 2,
|
||||
radiusY: height / 2,
|
||||
rotationDeg: 9,
|
||||
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
|
||||
}),
|
||||
depthZones: createDepthZones(detail.historyId, centerX, centerY),
|
||||
};
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [
|
||||
createHistory({
|
||||
historyId: "FH-2024-001",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "중구",
|
||||
floodYear: "2024",
|
||||
exmnYear: "2025",
|
||||
disasterName: "중앙동 저지대 침수",
|
||||
districtName: "중앙동 저지대",
|
||||
floodAreaHa: 1.8,
|
||||
avgFloodDepthM: 0.62,
|
||||
floodDepthM: 0.62,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2024-07-18",
|
||||
endDate: "2024-07-18",
|
||||
startTime: "14:10",
|
||||
endTime: "18:20",
|
||||
floodHour: "4.2시간",
|
||||
causeDetailCode: "FC-01",
|
||||
causeDetailName: "집중강우",
|
||||
damageAmount: "120,000,000원",
|
||||
rainStartTime: "13:50",
|
||||
rainEndTime: "18:20",
|
||||
totalRainfallMm: 91,
|
||||
rainfallIntensityMmHr: 31,
|
||||
centerX: 1122000,
|
||||
centerY: 1705000,
|
||||
width: 18000,
|
||||
height: 14000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2023-002",
|
||||
ctpvNm: "경상남도",
|
||||
sggNm: "창원시",
|
||||
floodYear: "2023",
|
||||
exmnYear: "2024",
|
||||
disasterName: "팔용동 공단 배수지연 침수",
|
||||
districtName: "팔용동 공단",
|
||||
floodAreaHa: 4.8,
|
||||
avgFloodDepthM: 1.92,
|
||||
floodDepthM: 1.92,
|
||||
gradeLabel: "2등급",
|
||||
startDate: "2023-08-11",
|
||||
endDate: "2023-08-11",
|
||||
startTime: "09:40",
|
||||
endTime: "15:10",
|
||||
floodHour: "5.5시간",
|
||||
causeDetailCode: "FC-04",
|
||||
causeDetailName: "배수시설 처리능력 초과",
|
||||
damageAmount: "340,000,000원",
|
||||
rainStartTime: "08:55",
|
||||
rainEndTime: "15:05",
|
||||
totalRainfallMm: 138,
|
||||
rainfallIntensityMmHr: 42,
|
||||
centerX: 1081000,
|
||||
centerY: 1778000,
|
||||
width: 25000,
|
||||
height: 17000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2022-003",
|
||||
ctpvNm: "울산광역시",
|
||||
sggNm: "남구",
|
||||
floodYear: "2022",
|
||||
exmnYear: "2023",
|
||||
disasterName: "태화강 제내지 도로 침수",
|
||||
districtName: "여천동 하류부",
|
||||
floodAreaHa: 3.1,
|
||||
avgFloodDepthM: 1.15,
|
||||
floodDepthM: 1.15,
|
||||
gradeLabel: "2등급",
|
||||
startDate: "2022-09-06",
|
||||
endDate: "2022-09-06",
|
||||
startTime: "07:20",
|
||||
endTime: "12:40",
|
||||
floodHour: "5.3시간",
|
||||
causeDetailCode: "FC-02",
|
||||
causeDetailName: "하천 수위 상승",
|
||||
damageAmount: "210,000,000원",
|
||||
rainStartTime: "06:30",
|
||||
rainEndTime: "11:50",
|
||||
totalRainfallMm: 117,
|
||||
rainfallIntensityMmHr: 37,
|
||||
centerX: 1168000,
|
||||
centerY: 1811000,
|
||||
width: 22000,
|
||||
height: 16000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2021-004",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "서구",
|
||||
floodYear: "2021",
|
||||
exmnYear: "2022",
|
||||
disasterName: "서대신동 급경사지 유출 침수",
|
||||
districtName: "서대신동 일원",
|
||||
floodAreaHa: 2.4,
|
||||
avgFloodDepthM: 0.85,
|
||||
floodDepthM: 0.85,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2021-07-07",
|
||||
endDate: "2021-07-07",
|
||||
startTime: "16:00",
|
||||
endTime: "20:10",
|
||||
floodHour: "4.1시간",
|
||||
causeDetailCode: "FC-03",
|
||||
causeDetailName: "우수관로 월류",
|
||||
damageAmount: "175,000,000원",
|
||||
rainStartTime: "15:20",
|
||||
rainEndTime: "19:50",
|
||||
totalRainfallMm: 104,
|
||||
rainfallIntensityMmHr: 29,
|
||||
centerX: 1111000,
|
||||
centerY: 1709000,
|
||||
width: 20000,
|
||||
height: 14500,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2020-005",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "영도구",
|
||||
floodYear: "2020",
|
||||
exmnYear: "2021",
|
||||
disasterName: "청학동 해안도로 월파 침수",
|
||||
districtName: "청학동 해안부",
|
||||
floodAreaHa: 1.2,
|
||||
avgFloodDepthM: 0.48,
|
||||
floodDepthM: 0.48,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2020-10-22",
|
||||
endDate: "2020-10-22",
|
||||
startTime: "03:10",
|
||||
endTime: "06:30",
|
||||
floodHour: "3.3시간",
|
||||
causeDetailCode: "FC-06",
|
||||
causeDetailName: "해안 월파",
|
||||
damageAmount: "82,000,000원",
|
||||
rainStartTime: "02:40",
|
||||
rainEndTime: "05:50",
|
||||
totalRainfallMm: 55,
|
||||
rainfallIntensityMmHr: 18,
|
||||
centerX: 1129000,
|
||||
centerY: 1695000,
|
||||
width: 15000,
|
||||
height: 12000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2019-006",
|
||||
ctpvNm: "경상남도",
|
||||
sggNm: "김해시",
|
||||
floodYear: "2019",
|
||||
exmnYear: "2020",
|
||||
disasterName: "장유 배수분구 내수 침수",
|
||||
districtName: "장유 배수분구",
|
||||
floodAreaHa: 2.9,
|
||||
avgFloodDepthM: 0.96,
|
||||
floodDepthM: 0.96,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2019-09-21",
|
||||
endDate: "2019-09-21",
|
||||
startTime: "11:30",
|
||||
endTime: "16:15",
|
||||
floodHour: "4.8시간",
|
||||
causeDetailCode: "FC-05",
|
||||
causeDetailName: "배수펌프 지연 가동",
|
||||
damageAmount: "143,000,000원",
|
||||
rainStartTime: "10:45",
|
||||
rainEndTime: "15:50",
|
||||
totalRainfallMm: 88,
|
||||
rainfallIntensityMmHr: 24,
|
||||
centerX: 1098000,
|
||||
centerY: 1734000,
|
||||
width: 21000,
|
||||
height: 15000,
|
||||
}),
|
||||
];
|
||||
|
||||
export const normalizeFloodHistoryFilters = (
|
||||
filters: FloodHistoryFilters,
|
||||
): FloodHistoryFilters => ({
|
||||
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
|
||||
sggCd: String(filters.sggCd ?? "0") || "0",
|
||||
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
|
||||
disasterName: String(filters.disasterName ?? "").trim(),
|
||||
});
|
||||
|
||||
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
|
||||
|
||||
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
|
||||
|
||||
export const buildFloodHistoryTableRows = (
|
||||
items: FloodHistoryItem[],
|
||||
page: number,
|
||||
pageSize = FLOOD_HISTORY_PAGE_SIZE,
|
||||
): FloodHistoryTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: (page - 1) * pageSize + index + 1,
|
||||
exmnYearDisplay: `${item.exmnYear}년`,
|
||||
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
|
||||
causeDetailDisplay: item.causeDetailName,
|
||||
startDateDisplay: item.startDate,
|
||||
endDateDisplay: item.endDate,
|
||||
startTimeDisplay: item.startTime,
|
||||
endTimeDisplay: item.endTime,
|
||||
causeCodeDisplay: item.causeDetailCode,
|
||||
districtNameDisplay: item.districtName,
|
||||
damageAmountDisplay: item.damageAmount,
|
||||
floodHourDisplay: item.floodHour,
|
||||
floodYearDisplay: `${item.floodYear}년`,
|
||||
}));
|
||||
|
||||
export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) =>
|
||||
Array.from(new Set(items.map((item) => item.floodYear)))
|
||||
.sort((left, right) => Number(right) - Number(left));
|
||||
|
||||
const resolveFloodHistoryNodeName = (node: any) =>
|
||||
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
|
||||
|
||||
const resolveFloodHistoryLayerType = (node: any) =>
|
||||
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
|
||||
|
||||
export const isFloodHistoryNode = (node: any) => {
|
||||
const name = resolveFloodHistoryNodeName(node);
|
||||
const layerType = resolveFloodHistoryLayerType(node);
|
||||
return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE;
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
|
||||
import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes";
|
||||
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
|
||||
|
||||
type Props = {
|
||||
change: GisEditChange;
|
||||
featureData: any;
|
||||
onClose: () => void;
|
||||
onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void;
|
||||
};
|
||||
|
||||
type MngInstOption = {
|
||||
mngInstCd: string;
|
||||
lwrInstNm: string;
|
||||
};
|
||||
|
||||
const EMPTY_SELECT_OPTION = { value: "", label: "선택" };
|
||||
|
||||
const PIPE_BASIC_INFO_FIELDS = [
|
||||
{ id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true },
|
||||
{ id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true },
|
||||
{ id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true },
|
||||
{ id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" },
|
||||
{ id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" },
|
||||
{ id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" },
|
||||
{ id: "prlgVl", sourceId: "prlg_vl", title: "연장(m)", type: "input" },
|
||||
{
|
||||
id: "usgCd",
|
||||
sourceId: "usg_cd",
|
||||
title: "용도",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mqltCd",
|
||||
sourceId: "mqlt_cd",
|
||||
title: "재질",
|
||||
type: "select",
|
||||
options: [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
|
||||
],
|
||||
},
|
||||
{ id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" },
|
||||
{ id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" },
|
||||
{ id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" },
|
||||
{ id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" },
|
||||
{ id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" },
|
||||
{ id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" },
|
||||
{ id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" },
|
||||
{ id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" },
|
||||
{ id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" },
|
||||
{ id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" },
|
||||
];
|
||||
|
||||
const PIPE_INPUT_INFO_FIELDS = [
|
||||
{ id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] },
|
||||
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
|
||||
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
|
||||
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
|
||||
{ id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] },
|
||||
{ id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] },
|
||||
{ id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] },
|
||||
];
|
||||
|
||||
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id);
|
||||
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id);
|
||||
|
||||
const toEditableText = (value: unknown) => {
|
||||
const text = String(value ?? "").trim();
|
||||
return text === "-" ? "" : text;
|
||||
};
|
||||
|
||||
const isInvalidPipeLookupKey = (value: string) =>
|
||||
!value || value === "-" || /^NEW-/i.test(value) || /^(null|undefined)$/i.test(value);
|
||||
|
||||
const toDateInputValue = (value: unknown) => {
|
||||
const text = toEditableText(value).replaceAll(".", "").replaceAll("-", "");
|
||||
if (!/^\d{8}$/.test(text)) return "";
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
};
|
||||
|
||||
const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", "");
|
||||
|
||||
const getObjectValue = (values: Record<string, any>, field: { id: string; sourceId?: string }) =>
|
||||
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
|
||||
|
||||
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
|
||||
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
|
||||
if (Object.prototype.hasOwnProperty.call(values, fieldId)) {
|
||||
acc[fieldId] = values[fieldId];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const normalizeBasicFormValues = (values: Record<string, any>) => {
|
||||
const nextValues = pickFields(values, PIPE_VISIBLE_BASIC_FIELD_IDS);
|
||||
if (Object.prototype.hasOwnProperty.call(nextValues, "instlYmd")) {
|
||||
nextValues.instlYmd = toDateInputValue(nextValues.instlYmd);
|
||||
}
|
||||
return nextValues;
|
||||
};
|
||||
|
||||
const buildBaseFeatureValues = (featureData: any) => {
|
||||
const resolveUsageCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
const resolveMaterialCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
const sourceValues = featureData?.values_ ?? featureData ?? {};
|
||||
return PIPE_BASIC_INFO_FIELDS.reduce<Record<string, any>>((acc, field) => {
|
||||
const rawValue = getObjectValue(sourceValues, field);
|
||||
if (field.id === "usgCd") {
|
||||
acc[field.id] = resolveUsageCode(rawValue);
|
||||
return acc;
|
||||
}
|
||||
if (field.id === "mqltCd") {
|
||||
acc[field.id] = resolveMaterialCode(rawValue);
|
||||
return acc;
|
||||
}
|
||||
if (field.id === "instlYmd") {
|
||||
acc[field.id] = toDateInputValue(rawValue);
|
||||
return acc;
|
||||
}
|
||||
acc[field.id] = toEditableText(rawValue);
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getPipeUsageLabel = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw;
|
||||
};
|
||||
|
||||
const buildInitialInputDraft = (change: GisEditChange) => ({
|
||||
instlMbdyCd: toEditableText(change.basicInfo?.instlMbdyCd ?? ""),
|
||||
swtrmtZoneNm: toEditableText(change.basicInfo?.swtrmtZoneNm ?? ""),
|
||||
mltvZoneNm: toEditableText(change.basicInfo?.mltvZoneNm ?? ""),
|
||||
mltvDtdvNm: toEditableText(change.basicInfo?.mltvDtdvNm ?? ""),
|
||||
lineCd: toEditableText(change.basicInfo?.lineCd ?? ""),
|
||||
floCd: toEditableText(change.basicInfo?.floCd ?? ""),
|
||||
layCd: toEditableText(change.basicInfo?.layCd ?? ""),
|
||||
});
|
||||
|
||||
export default function GisEditPipeSessionPopup({
|
||||
change,
|
||||
featureData,
|
||||
onClose,
|
||||
onSave,
|
||||
}: Props) {
|
||||
const isNewPipe = change.editType === "ADD";
|
||||
const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]);
|
||||
const [basicInfoTableData, setBasicInfoTableData] = useState<Record<string, any>>({});
|
||||
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
|
||||
buildInitialInputDraft(change),
|
||||
);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchMngInstOptions = async () => {
|
||||
try {
|
||||
const result = await getData("v1/fcltppl/mngInsts");
|
||||
if (!isMounted) return;
|
||||
setMngInstOptions(Array.isArray(result) ? result : []);
|
||||
} catch {
|
||||
if (isMounted) setMngInstOptions([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMngInstOptions();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mngInstSelectOptions = useMemo(
|
||||
() => {
|
||||
const sortedOptions = [...mngInstOptions].sort((a, b) =>
|
||||
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
|
||||
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
|
||||
"ko",
|
||||
),
|
||||
);
|
||||
return [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...sortedOptions.map((option) => ({
|
||||
value: String(option.mngInstCd ?? "").trim(),
|
||||
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
|
||||
})),
|
||||
];
|
||||
},
|
||||
[mngInstOptions],
|
||||
);
|
||||
|
||||
const basicInfoFields = useMemo(
|
||||
() =>
|
||||
PIPE_BASIC_INFO_FIELDS.map((field) =>
|
||||
field.id === "mngInstCd"
|
||||
? { ...field, options: mngInstSelectOptions }
|
||||
: field,
|
||||
),
|
||||
[mngInstSelectOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const initialBasicValues: Record<string, any> = {
|
||||
...baseFeatureValues,
|
||||
...normalizeBasicFormValues(change.basicInfo ?? {}),
|
||||
};
|
||||
|
||||
setBasicInfoTableData(initialBasicValues);
|
||||
setInputInfoTableData(buildInitialInputDraft(change));
|
||||
}, [baseFeatureValues, change.changeId, isNewPipe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewPipe) return;
|
||||
|
||||
const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim();
|
||||
const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim();
|
||||
if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd)) return;
|
||||
|
||||
let isMounted = true;
|
||||
const fetchPipeInputInfo = async () => {
|
||||
try {
|
||||
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
|
||||
const safeMngInstCd = encodeURIComponent(mngInstCd);
|
||||
const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`);
|
||||
if (!isMounted || !result) return;
|
||||
|
||||
setInputInfoTableData((previous) => ({
|
||||
...previous,
|
||||
...pickFields(result, PIPE_VISIBLE_INPUT_FIELD_IDS),
|
||||
...pickFields(change.basicInfo ?? {}, PIPE_VISIBLE_INPUT_FIELD_IDS),
|
||||
}));
|
||||
} catch {
|
||||
// Optional pipe input details should not block the edit session.
|
||||
}
|
||||
};
|
||||
|
||||
fetchPipeInputInfo();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]);
|
||||
|
||||
const buildMergedDraft = (
|
||||
nextBasicInfoTableData: Record<string, any>,
|
||||
nextInputInfoTableData: Record<string, any>,
|
||||
): GisEditPipeBasicInfo => {
|
||||
const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS);
|
||||
const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS);
|
||||
if (basicDraft.instlYmd) {
|
||||
basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd);
|
||||
}
|
||||
return {
|
||||
...basicDraft,
|
||||
...inputDraft,
|
||||
};
|
||||
};
|
||||
|
||||
const handleBasicInfoChange = (nextFormData: Record<string, any>) => {
|
||||
setBasicInfoTableData(nextFormData);
|
||||
onSave(change.changeId, buildMergedDraft(nextFormData, inputInfoTableData));
|
||||
};
|
||||
|
||||
const handleInputInfoChange = (nextFormData: Record<string, any>) => {
|
||||
setInputInfoTableData(nextFormData);
|
||||
onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData));
|
||||
};
|
||||
|
||||
const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]);
|
||||
const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]);
|
||||
const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? "");
|
||||
|
||||
return (
|
||||
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
|
||||
<div className="card-header bg-primary flex items-center justify-between relative p-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
|
||||
하수관로
|
||||
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
|
||||
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
|
||||
size="xs"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1">
|
||||
<VerticalTable
|
||||
headName="기본정보"
|
||||
tableData={basicInfoTableRows}
|
||||
headlist={basicInfoFields}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => {}}
|
||||
isEditable={true}
|
||||
onFormDataChange={handleBasicInfoChange}
|
||||
/>
|
||||
<VerticalTable
|
||||
headName="입력정보"
|
||||
tableData={inputInfoTableRows}
|
||||
headlist={PIPE_INPUT_INFO_FIELDS}
|
||||
pageSize={20}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => {}}
|
||||
isEditable={true}
|
||||
onFormDataChange={handleInputInfoChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
|
||||
|
||||
type Props = GisEditSessionSnapshot & {
|
||||
onRemoveFocusedChange: () => void;
|
||||
onRemoveLastChange: () => void;
|
||||
onClearChanges: () => void;
|
||||
onFocusChange: (changeId: string) => void;
|
||||
onOpenPopup?: () => void;
|
||||
variant?: "inline" | "popup";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
`block w-full rounded border px-2 py-2 text-left text-[12px] transition ${
|
||||
isFocused
|
||||
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
|
||||
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
|
||||
}`;
|
||||
|
||||
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
|
||||
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
|
||||
|
||||
const getChangeSummary = (change: GisEditSessionSnapshot["changes"][number]) => {
|
||||
if (change.attributeOnly) return "변경한 상세정보를 확인하세요.";
|
||||
if (change.editType === "ADD") return "추가한 객체 위치를 확인하세요.";
|
||||
if (change.editType === "MOVE") return "객체를 드래그해 위치를 조정하세요.";
|
||||
if (change.editType === "VERTEX_EDIT") return "점을 옮기거나 선분을 끌어 추가하세요.";
|
||||
return "삭제할 객체가 맞는지 확인하세요.";
|
||||
};
|
||||
|
||||
type ChangeListProps = {
|
||||
changes: GisEditSessionSnapshot["changes"];
|
||||
focusedChangeId: string | null;
|
||||
onFocusChange: (changeId: string) => void;
|
||||
};
|
||||
|
||||
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
|
||||
<>
|
||||
{changes.map((change, index) => (
|
||||
<button
|
||||
key={change.changeId}
|
||||
type="button"
|
||||
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
|
||||
onClick={() => onFocusChange(change.changeId)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="shrink-0 whitespace-nowrap text-[11px] font-semibold leading-4 text-[#173f67]">
|
||||
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
|
||||
<span className="whitespace-nowrap rounded-full border border-[#a9d9bd] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#16834f]">
|
||||
정보저장
|
||||
</span>
|
||||
) : null}
|
||||
{focusedChangeId === change.changeId ? (
|
||||
<span className="whitespace-nowrap rounded-full border border-[#b8d3ef] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#176db8]">
|
||||
지도 미리보기 선택
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-[#526173]">{getChangeSummary(change)}</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
export default function GisEditSessionPanel({
|
||||
isEditMode,
|
||||
selectedLayerName,
|
||||
activeTool,
|
||||
changes,
|
||||
canUndo,
|
||||
focusedChangeId,
|
||||
onRemoveFocusedChange,
|
||||
onRemoveLastChange,
|
||||
onClearChanges,
|
||||
onFocusChange,
|
||||
onOpenPopup,
|
||||
variant = "inline",
|
||||
className = "",
|
||||
}: Props) {
|
||||
const isCompactInline = variant === "inline" && changes.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${isCompactInline ? "h-[58px] p-2" : "p-4"} ${className}`.trim()}
|
||||
>
|
||||
<div className={`flex shrink-0 justify-between gap-3 ${isCompactInline ? "mb-0 items-center" : "mb-3 items-start"}`}>
|
||||
<div className="min-w-0">
|
||||
{variant === "inline" ? (
|
||||
<div className="mb-1 text-[14px] font-bold text-[#173f67]">편집 세션</div>
|
||||
) : null}
|
||||
<div className="truncate text-[12px] text-[#667485]">
|
||||
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
|
||||
</div>
|
||||
</div>
|
||||
{onOpenPopup ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 rounded border border-[#b8d3ef] bg-white px-2.5 py-1.5 text-[11px] font-semibold text-[#176db8] transition hover:bg-[#eef7ff]"
|
||||
onClick={onOpenPopup}
|
||||
title="편집 세션을 기존 윈도우 팝업으로 열기"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[12px] leading-none">↗</span>
|
||||
<span>팝업</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2`}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
|
||||
disabled={!focusedChangeId}
|
||||
onClick={onRemoveFocusedChange}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
|
||||
disabled={!canUndo}
|
||||
onClick={onRemoveLastChange}
|
||||
>
|
||||
뒤로가기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
|
||||
disabled={!canUndo}
|
||||
onClick={onClearChanges}
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
|
||||
총 {changes.length}건
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}>
|
||||
{changes.length === 0 ? (
|
||||
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
|
||||
저장할 편집 변경분이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto pr-1">
|
||||
<div className="space-y-2">
|
||||
<ChangeList
|
||||
changes={changes}
|
||||
focusedChangeId={focusedChangeId}
|
||||
onFocusChange={onFocusChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Move,
|
||||
PenTool,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
|
||||
type Props = {
|
||||
isEditMode: boolean;
|
||||
activeTool: GisEditTool | null;
|
||||
isSnapEnabled: boolean;
|
||||
canUseTools: boolean;
|
||||
canSave: boolean;
|
||||
isSaving?: boolean;
|
||||
statusMessage: string;
|
||||
allowedTools?: GisEditTool[];
|
||||
onClose?: () => void;
|
||||
onToggleSnap: () => void;
|
||||
onSelectTool: (tool: GisEditTool) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
const TOOL_BUTTONS: Array<{
|
||||
tool: GisEditTool;
|
||||
label: string;
|
||||
icon: typeof Plus;
|
||||
}> = [
|
||||
{ tool: "ADD", label: "추가", icon: Plus },
|
||||
{ tool: "MOVE", label: "이동", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
|
||||
{ tool: "DELETE", label: "삭제", icon: Trash2 },
|
||||
];
|
||||
|
||||
export default function GisEditToolbar({
|
||||
isEditMode,
|
||||
activeTool,
|
||||
isSnapEnabled,
|
||||
canUseTools,
|
||||
canSave,
|
||||
isSaving = false,
|
||||
statusMessage: _statusMessage,
|
||||
allowedTools = [],
|
||||
onClose,
|
||||
onToggleSnap,
|
||||
onSelectTool,
|
||||
onSave,
|
||||
}: Props) {
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<div data-testid="gis-edit-toolbar" className="absolute bottom-12 left-1/2 z-[60] flex w-[min(960px,calc(100%-2rem))] -translate-x-1/2 flex-col">
|
||||
<div className="card card-border flex !bg-white py-[1px]">
|
||||
<div className="flex w-full items-center gap-2 overflow-hidden">
|
||||
<div className="flex w-[120px] shrink-0 items-center justify-start">
|
||||
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 justify-center">
|
||||
<div className="flex items-center gap-1">
|
||||
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
|
||||
const isActive = activeTool === tool;
|
||||
const isToolEnabled =
|
||||
canUseTools && allowedTools.includes(tool);
|
||||
if (tool === "VERTEX_EDIT") {
|
||||
return (
|
||||
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[98px] px-2"
|
||||
disabled={!isToolEnabled}
|
||||
onClick={() => onSelectTool("VERTEX_EDIT")}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color={isSnapEnabled ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[82px] px-2"
|
||||
disabled={!canUseTools}
|
||||
onClick={onToggleSnap}
|
||||
title="이동과 꼭지점편집에 스냅핑 적용"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>스냅핑</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={tool}
|
||||
type="button"
|
||||
color={isActive ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
|
||||
disabled={!isToolEnabled}
|
||||
onClick={() => onSelectTool(tool)}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[132px] shrink-0 items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mt-1 min-w-[58px] shrink-0 px-2"
|
||||
disabled={!canSave || isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
{isSaving ? "저장 중" : "저장"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="sm"
|
||||
className="mt-1 min-w-[58px] shrink-0 px-2"
|
||||
onClick={onClose}
|
||||
title="하수관로 편집 닫기"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<X className="h-4 w-4" />
|
||||
<span>닫기</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
|
||||
|
||||
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
|
||||
|
||||
export type GisEditPreviewColors = {
|
||||
pointColor: string;
|
||||
lineColor: string;
|
||||
fillColor: string;
|
||||
};
|
||||
|
||||
export type GisEditFeatureRef = {
|
||||
fcltMngNo?: string;
|
||||
mngInstCd?: string;
|
||||
fid?: string;
|
||||
};
|
||||
|
||||
export type GisEditPipeBasicInfo = {
|
||||
[key: string]: string | null | undefined;
|
||||
fcltMngNo?: string | null;
|
||||
mngInstCd?: string | null;
|
||||
instMngNo?: string | null;
|
||||
instlYmd?: string | null;
|
||||
mapshtNo?: string | null;
|
||||
pdmtVl?: string | null;
|
||||
prlgVl?: string | null;
|
||||
usgCd?: string | null;
|
||||
mqltCd?: string | null;
|
||||
avgGrdn?: string | null;
|
||||
drtwMltvArea?: string | null;
|
||||
exclnMltvArea?: string | null;
|
||||
tmptDpth?: string | null;
|
||||
tmptMhlMngNo?: string | null;
|
||||
scendDpth?: string | null;
|
||||
scendMhlMngNo?: string | null;
|
||||
trflnCnt?: string | null;
|
||||
vrtcLen?: string | null;
|
||||
wdthLen?: string | null;
|
||||
instlMbdyCd?: string | null;
|
||||
swtrmtZoneNm?: string | null;
|
||||
mltvZoneNm?: string | null;
|
||||
mltvDtdvNm?: string | null;
|
||||
lineCd?: string | null;
|
||||
floCd?: string | null;
|
||||
layCd?: string | null;
|
||||
};
|
||||
|
||||
export type GisEditLayerInfo = {
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
geometryType: GisEditGeometryType;
|
||||
editableYn: "Y" | "N";
|
||||
allowedTools: GisEditTool[];
|
||||
previewColors: GisEditPreviewColors;
|
||||
disabledReason?: string;
|
||||
};
|
||||
|
||||
export type GisEditChange = {
|
||||
changeId: string;
|
||||
editType: GisEditTool;
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
objectId?: string;
|
||||
fid?: string;
|
||||
baseEditSn?: string;
|
||||
featureRef?: GisEditFeatureRef;
|
||||
objectLabel: string;
|
||||
beforeGeometry?: Record<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
featureProperties?: Record<string, unknown> | null;
|
||||
basicInfo?: GisEditPipeBasicInfo | null;
|
||||
basicInfoSavedAt?: string | null;
|
||||
attributeOnly?: boolean;
|
||||
summary: string;
|
||||
createdAt: string;
|
||||
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
|
||||
};
|
||||
|
||||
export type GisEditSavePayload = {
|
||||
screenId: string;
|
||||
layerId: string;
|
||||
layerName: string;
|
||||
saveTargetType: "VERSION" | "FINAL";
|
||||
view: {
|
||||
srs: string;
|
||||
bbox: string | null;
|
||||
};
|
||||
changes: Array<{
|
||||
changeId: string;
|
||||
editType: GisEditTool;
|
||||
objectId?: string;
|
||||
clientTempId?: string;
|
||||
fid?: string;
|
||||
baseEditSn?: string;
|
||||
featureRef?: GisEditFeatureRef;
|
||||
attributes?: GisEditPipeBasicInfo | null;
|
||||
beforeGeometry?: Record<string, unknown> | null;
|
||||
afterGeometry?: Record<string, unknown> | null;
|
||||
geometry?: Record<string, unknown> | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GisEditSessionSnapshot = {
|
||||
isEditMode: boolean;
|
||||
selectedLayerName: string;
|
||||
activeTool: GisEditTool | null;
|
||||
isSnapEnabled: boolean;
|
||||
changes: GisEditChange[];
|
||||
lastSavedPayload: GisEditSavePayload | null;
|
||||
canUndo: boolean;
|
||||
focusedChangeId: string | null;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
|
||||
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
type UploadFacilityType = {
|
||||
facilityTypeCode: string;
|
||||
facilityTypeName: string;
|
||||
allowedExtensions: string[];
|
||||
maxFileSizeMb: number;
|
||||
};
|
||||
|
||||
type UploadHistoryItem = {
|
||||
uploadId: string;
|
||||
facilityTypeCode: string;
|
||||
facilityTypeName: string;
|
||||
fileName: string;
|
||||
registeredAt: string;
|
||||
registeredBy: string;
|
||||
};
|
||||
|
||||
const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [
|
||||
{
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"],
|
||||
maxFileSizeMb: 200,
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260504-0001",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260504.zip",
|
||||
registeredAt: "2026-05-04",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260418-0004",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260418.zip",
|
||||
registeredAt: "2026-04-18",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260409-0007",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260409.zip",
|
||||
registeredAt: "2026-04-09",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260331-0010",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260331.zip",
|
||||
registeredAt: "2026-03-31",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260322-0013",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260322.zip",
|
||||
registeredAt: "2026-03-22",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260313-0016",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260313.zip",
|
||||
registeredAt: "2026-03-13",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
{
|
||||
uploadId: "GUP-MOCK-20260304-0019",
|
||||
facilityTypeCode: "PPL",
|
||||
facilityTypeName: "하수관로",
|
||||
fileName: "asan_pipe_20260304.zip",
|
||||
registeredAt: "2026-03-04",
|
||||
registeredBy: "admin",
|
||||
},
|
||||
];
|
||||
|
||||
const getFileExtension = (fileName: string) => {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
if (lastDotIndex < 0) return "";
|
||||
return fileName.slice(lastDotIndex).toLowerCase();
|
||||
};
|
||||
|
||||
const buildTodayDate = () => new Date().toISOString().slice(0, 10);
|
||||
const GIS_UPLOAD_PAGE_SIZE = 10;
|
||||
|
||||
export default function GisManagementPopupPage() {
|
||||
// 브라우저 파일 picker는 제어형 input보다 ref 제어가 안전하다.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>(
|
||||
INITIAL_UPLOAD_HISTORY,
|
||||
);
|
||||
const [selectedUploadId, setSelectedUploadId] = useState<string>(
|
||||
INITIAL_UPLOAD_HISTORY[0]?.uploadId ?? "",
|
||||
);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const selectedFacility = useMemo(
|
||||
() =>
|
||||
UPLOAD_FACILITY_TYPES.find(
|
||||
(facilityType) =>
|
||||
facilityType.facilityTypeCode === selectedFacilityTypeCode,
|
||||
) ?? null,
|
||||
[selectedFacilityTypeCode],
|
||||
);
|
||||
|
||||
const selectedHistory = useMemo(
|
||||
() =>
|
||||
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
|
||||
null,
|
||||
[selectedUploadId, uploadHistory],
|
||||
);
|
||||
|
||||
const pagedUploadHistory = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE;
|
||||
return uploadHistory.slice(startIndex, startIndex + GIS_UPLOAD_PAGE_SIZE);
|
||||
}, [currentPage, uploadHistory]);
|
||||
|
||||
const resetSelectedFile = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleFacilityTypeChange = (
|
||||
event: ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
setSelectedFacilityTypeCode(event.target.value);
|
||||
resetSelectedFile();
|
||||
};
|
||||
|
||||
const handleOpenFilePicker = () => {
|
||||
if (!selectedFacility) {
|
||||
window.alert("시설구분을 먼저 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
|
||||
if (!selectedFacility) {
|
||||
resetSelectedFile();
|
||||
window.alert("시설구분을 먼저 선택한 뒤 파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextFile) {
|
||||
resetSelectedFile();
|
||||
return;
|
||||
}
|
||||
|
||||
const fileExtension = getFileExtension(nextFile.name);
|
||||
const isAllowedExtension = selectedFacility.allowedExtensions.includes(
|
||||
fileExtension,
|
||||
);
|
||||
|
||||
if (!isAllowedExtension) {
|
||||
resetSelectedFile();
|
||||
window.alert(
|
||||
`업로드 가능한 GIS 파일 형식이 아닙니다. 허용 형식: ${selectedFacility.allowedExtensions.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxFileSizeInBytes = selectedFacility.maxFileSizeMb * 1024 * 1024;
|
||||
if (nextFile.size > maxFileSizeInBytes) {
|
||||
resetSelectedFile();
|
||||
window.alert(
|
||||
`파일 크기가 제한을 초과했습니다. 최대 ${selectedFacility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFile(nextFile);
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!selectedFacility) {
|
||||
window.alert("시설구분을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
window.alert("업로드할 파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isDuplicateFile = uploadHistory.some(
|
||||
(history) =>
|
||||
history.facilityTypeCode === selectedFacility.facilityTypeCode &&
|
||||
history.fileName.toLowerCase() === selectedFile.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if (isDuplicateFile) {
|
||||
window.alert("동일 시설구분에 같은 파일명이 존재합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUploadHistory: UploadHistoryItem = {
|
||||
uploadId: `GUP-MOCK-${Date.now()}`,
|
||||
facilityTypeCode: selectedFacility.facilityTypeCode,
|
||||
facilityTypeName: selectedFacility.facilityTypeName,
|
||||
fileName: selectedFile.name,
|
||||
registeredAt: buildTodayDate(),
|
||||
registeredBy: "admin",
|
||||
};
|
||||
|
||||
setUploadHistory((currentHistory) => [
|
||||
nextUploadHistory,
|
||||
...currentHistory,
|
||||
]);
|
||||
setCurrentPage(1);
|
||||
setSelectedUploadId(nextUploadHistory.uploadId);
|
||||
window.alert("GIS 업로드가 완료되었습니다.");
|
||||
resetSelectedFile();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!selectedHistory) {
|
||||
window.alert("다운로드할 파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(`${selectedHistory.fileName} 다운로드를 요청했습니다.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-modal-page">
|
||||
<section className="sn-popup-modal-shell max-w-[920px] shadow-[0_16px_36px_rgba(31,59,87,0.10)] backdrop-blur-[2px]">
|
||||
<header className="sn-popup-modal-header">
|
||||
<h1 className="sn-popup-modal-title">
|
||||
GIS 관리
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="px-[16px] pb-[16px] pt-[10px]">
|
||||
<div className="sn-popup-panel-title mb-[10px] mt-[4px]">
|
||||
GIS 업로드
|
||||
</div>
|
||||
|
||||
<div className="mb-[8px] flex items-center justify-between gap-[12px] px-[2px]">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-[10px]">
|
||||
<select
|
||||
className="sn-popup-input-sm h-[32px] w-[110px] shrink-0"
|
||||
value={selectedFacilityTypeCode}
|
||||
onChange={handleFacilityTypeChange}
|
||||
aria-label="시설구분"
|
||||
>
|
||||
<option value="">시설구분</option>
|
||||
{UPLOAD_FACILITY_TYPES.map((facilityType) => (
|
||||
<option
|
||||
key={facilityType.facilityTypeCode}
|
||||
value={facilityType.facilityTypeCode}
|
||||
>
|
||||
{facilityType.facilityTypeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Input
|
||||
value={selectedFile?.name ?? ""}
|
||||
placeholder="선택된 파일 없음"
|
||||
readOnly
|
||||
aria-label="선택 파일명"
|
||||
className="sn-popup-input-sm h-[32px] min-w-0 flex-1 py-0 !text-[1.02rem] shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-[10px]">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="sn-popup-primary-button-sm h-[32px] min-w-[56px]"
|
||||
onClick={handleOpenFilePicker}
|
||||
>
|
||||
찾기
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="sn-popup-primary-button-sm h-[32px] min-w-[64px]"
|
||||
onClick={handleUpload}
|
||||
>
|
||||
업로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={
|
||||
selectedFacility?.allowedExtensions.join(",") ??
|
||||
".zip,.shp,.geojson,.json,.dxf"
|
||||
}
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
|
||||
<div className="mb-[8px] flex items-center justify-between px-[2px]">
|
||||
<div className="sn-popup-panel-title">
|
||||
GIS 업로드 현황
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button-sm h-[32px] min-w-[82px] px-[10px]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="sn-popup-card shadow-[0_6px_16px_rgba(31,59,87,0.06),inset_0_1px_0_rgba(255,255,255,0.78)]">
|
||||
<Table variant="secondary" className="text-[1.02rem]">
|
||||
<TableHeader className="!static bg-transparent">
|
||||
<TableRow className="border-0">
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
no
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
시설구분
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
파일명
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
등록일자
|
||||
</TableHead>
|
||||
<TableHead className="h-[32px] border border-border bg-secondary px-[12px] py-[5px] text-left text-[1rem] font-medium text-title">
|
||||
등록자
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{pagedUploadHistory.map((history, index) => {
|
||||
const isSelected = history.uploadId === selectedUploadId;
|
||||
const backgroundColor = isSelected
|
||||
? "var(--primary-transparent)"
|
||||
: index % 2 === 0
|
||||
? "var(--background)"
|
||||
: "var(--secondary)";
|
||||
const rowNumber =
|
||||
(currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={history.uploadId}
|
||||
className="border-0 cursor-pointer"
|
||||
onClick={() => setSelectedUploadId(history.uploadId)}
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{rowNumber}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.facilityTypeName}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.fileName}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.registeredAt}
|
||||
</TableCell>
|
||||
<TableCell className="h-[40px] border border-border px-[12px] py-[8px] text-[1rem] font-normal text-foreground">
|
||||
{history.registeredBy}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? (
|
||||
<div className="mt-[10px] flex justify-center">
|
||||
<UiPageNations
|
||||
total={uploadHistory.length}
|
||||
cur={currentPage}
|
||||
pageSize={GIS_UPLOAD_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
import {
|
||||
INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
|
||||
type InftwtResultRow,
|
||||
} from "./inftwtResultTypes";
|
||||
|
||||
const INFTWT_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const INFTWT_RESULT_HEADLIST = [
|
||||
{ id: "rowNumber", title: "일련번호" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "적용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
|
||||
type InftwtResultFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
sggName: string;
|
||||
swtrmtFcltNm: string;
|
||||
aplcnYmFrom: string;
|
||||
aplcnYmTo: string;
|
||||
};
|
||||
|
||||
const createDefaultAplcnYm = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: InftwtResultFilters = {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
sggName: "",
|
||||
swtrmtFcltNm: "",
|
||||
aplcnYmFrom: `${new Date().getFullYear()}01`,
|
||||
aplcnYmTo: createDefaultAplcnYm(),
|
||||
};
|
||||
|
||||
const postRowSelectMessage = (row: InftwtResultRow) => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
row,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("침입수 분석결과 선택 동기화 저장에 실패했습니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || !window.opener) return;
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
export default function InftwtResultPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
|
||||
const [selectedRow, setSelectedRow] = useState<InftwtResultRow | null>(null);
|
||||
const [rows, setRows] = useState<InftwtResultRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: InftwtResultFilters) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(INFTWT_RESULT_PAGE_SIZE),
|
||||
sort: "inftwtAnlsSn,desc",
|
||||
});
|
||||
|
||||
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
|
||||
|
||||
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
const nextRows = content
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
|
||||
const sggOption = sgg.find((code) => code.val === item?.sggCd);
|
||||
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
|
||||
scsYn: String(item?.scsYn ?? "N"),
|
||||
} as InftwtResultRow;
|
||||
})
|
||||
.filter((item: InftwtResultRow) => item.scsYn === "Y");
|
||||
|
||||
setRows(nextRows);
|
||||
setTotal(Number(response?.totalElements ?? nextRows.length));
|
||||
setCurrentPage(page);
|
||||
},
|
||||
[ctpv, sgg],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 목록 조회 중 오류:", error);
|
||||
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, DEFAULT_FILTERS);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (page = 1) => {
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedRow(row);
|
||||
postRowSelectMessage(row);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedRow?.inftwtAnlsSn) {
|
||||
alert("다운로드할 분석결과를 먼저 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
|
||||
const excelRows = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: {
|
||||
inftwtAnlsSn: "침입수분석\n일련번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 다운로드 중 오류:", error);
|
||||
alert("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">침입수 분석결과</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="rounded-md border border-border bg-secondary p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmFrom,
|
||||
onChange: (value: string) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
aplcnYmFrom: value,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
startYear={1900}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmTo,
|
||||
onChange: (value: string) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
aplcnYmTo: value,
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
startYear={1900}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={filters.ctpvCd}
|
||||
sigungu={filters.sggCd}
|
||||
sggName={filters.sggName}
|
||||
onSidoChange={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
ctpvCd: value,
|
||||
sggCd: "0",
|
||||
sggName: "",
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
onSigunguChange={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggCd: value,
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
setSggName={(value) => {
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggName: value ?? "",
|
||||
swtrmtFcltNm: "",
|
||||
}));
|
||||
}}
|
||||
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
|
||||
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
|
||||
setSwtrmtFcltNm={(value) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
swtrmtFcltNm: value ?? "",
|
||||
}))
|
||||
}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
size="sm"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<UiTable<InftwtResultRow>
|
||||
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
|
||||
tableData={rows}
|
||||
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
cellClassName={(_, headId) =>
|
||||
headId === "anlsInfo"
|
||||
? "whitespace-normal break-all leading-[1.35]"
|
||||
: ""
|
||||
}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRow ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
{"선택 분석: "}
|
||||
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
|
||||
export const INFTWT_RESULT_OVERLAY_TYPE = "INFTWT_RESULT";
|
||||
export const INFTWT_RESULT_ROW_SELECT_STORAGE_KEY = "SN_INFTWT_RESULT_ROW_SELECT_STORAGE";
|
||||
|
||||
export type InftwtResultRow = {
|
||||
rowNumber: number;
|
||||
inftwtAnlsSn: number;
|
||||
ctpvCd?: string;
|
||||
ctpvNm: string;
|
||||
sggCd?: string;
|
||||
sggNm: string;
|
||||
swtrmtFcltNm: string;
|
||||
aplcnYm: string;
|
||||
drtwSwtcRt?: number | string;
|
||||
anlsInfo: string;
|
||||
regDt?: string;
|
||||
rgtrNm?: string;
|
||||
scsYn?: string;
|
||||
};
|
||||
|
||||
export type InftwtResultMapViewPipe = {
|
||||
pplMngNo: string;
|
||||
fcltMngNo: string;
|
||||
hasOilq: boolean;
|
||||
};
|
||||
|
||||
export type InftwtResultMapViewResponse = {
|
||||
analysis: {
|
||||
inftwtAnlsSn: number;
|
||||
aplcnYm: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
swtrmtFcltNm?: string;
|
||||
anlsInfo?: string;
|
||||
};
|
||||
focusTargetType: "ZONE" | "PIPE_EXTENT";
|
||||
focusTargetName: string;
|
||||
focusLayerType?: "tb_ms_swage_prcs_zone" | "tb_ms_swage_ppl";
|
||||
focusFid?: string;
|
||||
focusFallbackApplied?: boolean;
|
||||
zoneGeometry: Record<string, unknown>;
|
||||
pipeGeometry: Record<string, unknown>;
|
||||
pipes: InftwtResultMapViewPipe[];
|
||||
pipeCount: number;
|
||||
oilqPipeCount: number;
|
||||
};
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
import VectorLayer from "ol/layer/Vector.js";
|
||||
import VectorSource from "ol/source/Vector.js";
|
||||
import Stroke from "ol/style/Stroke.js";
|
||||
import Fill from "ol/style/Fill.js";
|
||||
import Style from "ol/style/Style.js";
|
||||
import { intersects as intersectsExtent } from "ol/extent";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
|
||||
|
||||
import {
|
||||
INFTWT_RESULT_OVERLAY_TYPE,
|
||||
INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
|
||||
type InftwtResultMapViewResponse,
|
||||
type InftwtResultRow,
|
||||
} from "./inftwtResultTypes";
|
||||
|
||||
type UseInftwtResultMapParams = {
|
||||
gisMaps: MutableRefObject<any | null>;
|
||||
setFeatureData: (value: any) => void;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
setIsSearchModalOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER";
|
||||
const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER";
|
||||
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
|
||||
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
||||
|
||||
const zoneStyle = new Style({
|
||||
fill: new Fill({
|
||||
color: "rgba(16, 185, 129, 0.06)",
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: "rgba(5, 150, 105, 0.72)",
|
||||
width: 2,
|
||||
lineDash: [10, 8],
|
||||
}),
|
||||
zIndex: 19991,
|
||||
});
|
||||
|
||||
const buildPipeStyle = (hasOilq: boolean) => {
|
||||
const innerColor = hasOilq ? "rgba(236, 72, 153, 0.98)" : "rgba(14, 165, 233, 0.98)";
|
||||
const haloWidth = hasOilq ? 11 : 5;
|
||||
const innerWidth = hasOilq ? 6 : 2.5;
|
||||
const dashPattern = hasOilq ? [18, 9] : undefined;
|
||||
|
||||
return [
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(15, 23, 42, 0.58)",
|
||||
width: hasOilq ? haloWidth + 4 : haloWidth + 2,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
}),
|
||||
zIndex: hasOilq ? 20000 : 19999,
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(255, 255, 255, 0.98)",
|
||||
width: haloWidth,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
}),
|
||||
zIndex: hasOilq ? 20001 : 20000,
|
||||
}),
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: innerColor,
|
||||
width: innerWidth,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
lineDash: dashPattern,
|
||||
}),
|
||||
zIndex: hasOilq ? 20002 : 20001,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const getFeatureCollection = (payload: any) => {
|
||||
if (payload?.type === "FeatureCollection") return payload;
|
||||
if (payload?.data?.type === "FeatureCollection") return payload.data;
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
};
|
||||
|
||||
const normalizeProjectionName = (value: string) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) return "EPSG:4326";
|
||||
if (trimmed.startsWith("EPSG:")) return trimmed;
|
||||
const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/);
|
||||
return match?.[1] ? `EPSG:${match[1]}` : trimmed;
|
||||
};
|
||||
|
||||
const resolveFeatureCollectionProjection = (payload: any) => {
|
||||
const featureCollection = getFeatureCollection(payload);
|
||||
const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim();
|
||||
return normalizeProjectionName(crsName || GisApp.TO_PROJ);
|
||||
};
|
||||
|
||||
const getOverlayFlag = (data: any) =>
|
||||
String(
|
||||
data?.get?.("analysisOverlayType") ??
|
||||
data?.values_?.analysisOverlayType ??
|
||||
data?.analysisOverlayType ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayBasePplMngNo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("fcltMngNo") ??
|
||||
data?.values_?.fcltMngNo ??
|
||||
data?.values_?.fclt_mng_no ??
|
||||
data?.fcltMngNo ??
|
||||
data?.fclt_mng_no ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisPplMngNo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("pplMngNo") ??
|
||||
data?.values_?.pplMngNo ??
|
||||
data?.pplMngNo ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisSn = (data: any) =>
|
||||
Number(
|
||||
data?.get?.("inftwtAnlsSn") ??
|
||||
data?.values_?.inftwtAnlsSn ??
|
||||
data?.inftwtAnlsSn ??
|
||||
0,
|
||||
);
|
||||
|
||||
const getOverlayAplcnYm = (data: any) =>
|
||||
String(
|
||||
data?.get?.("aplcnYm") ??
|
||||
data?.values_?.aplcnYm ??
|
||||
data?.aplcnYm ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getOverlayAnalysisInfo = (data: any) =>
|
||||
String(
|
||||
data?.get?.("analysisInfo") ??
|
||||
data?.values_?.analysisInfo ??
|
||||
data?.analysisInfo ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getFirstFeatureId = (payload: any) => {
|
||||
const featureCollection = getFeatureCollection(payload);
|
||||
const firstFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : null;
|
||||
return String(firstFeature?.id ?? "").trim();
|
||||
};
|
||||
|
||||
const getOilqPipeExtent = (features: any[]) => {
|
||||
const oilqFeatures = features.filter((feature) => Boolean(feature?.get?.("hasOilq")));
|
||||
if (oilqFeatures.length === 0) return null;
|
||||
|
||||
let nextExtent: [number, number, number, number] | null = null;
|
||||
oilqFeatures.forEach((feature) => {
|
||||
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
|
||||
if (!Array.isArray(geometryExtent) || geometryExtent.some((value: number) => !Number.isFinite(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextExtent) {
|
||||
nextExtent = [...geometryExtent] as [number, number, number, number];
|
||||
return;
|
||||
}
|
||||
|
||||
nextExtent = [
|
||||
Math.min(nextExtent[0], geometryExtent[0]),
|
||||
Math.min(nextExtent[1], geometryExtent[1]),
|
||||
Math.max(nextExtent[2], geometryExtent[2]),
|
||||
Math.max(nextExtent[3], geometryExtent[3]),
|
||||
];
|
||||
});
|
||||
|
||||
return nextExtent;
|
||||
};
|
||||
|
||||
export const useInftwtResultMap = ({
|
||||
gisMaps,
|
||||
setFeatureData,
|
||||
setIsOpen,
|
||||
setIsSearchModalOpen,
|
||||
}: UseInftwtResultMapParams) => {
|
||||
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
|
||||
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const zoneSourceRef = useRef<VectorSource | null>(null);
|
||||
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const pipeSourceRef = useRef<VectorSource | null>(null);
|
||||
const mapViewCacheRef = useRef<Map<number, InftwtResultMapViewResponse>>(new Map());
|
||||
const [activeMapView, setActiveMapView] = useState<InftwtResultMapViewResponse | null>(null);
|
||||
|
||||
const clearLayers = useCallback(() => {
|
||||
zoneSourceRef.current?.clear?.();
|
||||
pipeSourceRef.current?.clear?.();
|
||||
zoneLayerRef.current?.changed?.();
|
||||
pipeLayerRef.current?.changed?.();
|
||||
gisMaps.current?.renderSync?.();
|
||||
}, [gisMaps]);
|
||||
|
||||
const ensureZoneLayer = useCallback(() => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) return null;
|
||||
|
||||
if (zoneLayerRef.current && zoneSourceRef.current) {
|
||||
return zoneSourceRef.current;
|
||||
}
|
||||
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 19991,
|
||||
});
|
||||
|
||||
layer.set("id", INFTWT_ZONE_LAYER_ID);
|
||||
layer.set("lyrNm", "移⑥엯?섎텇??泥섎━援ъ뿭");
|
||||
layer.set("lyrTyNm", "tb_ms_swage_prcs_zone");
|
||||
layer.set("clickPsblYn", "N");
|
||||
layer.setVisible?.(true);
|
||||
map.addLayer(layer);
|
||||
|
||||
zoneLayerRef.current = layer;
|
||||
zoneSourceRef.current = source;
|
||||
return source;
|
||||
}, [gisMaps]);
|
||||
|
||||
const ensurePipeLayer = useCallback(() => {
|
||||
const map = gisMaps.current;
|
||||
if (!map) return null;
|
||||
|
||||
if (pipeLayerRef.current && pipeSourceRef.current) {
|
||||
return pipeSourceRef.current;
|
||||
}
|
||||
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 19997,
|
||||
});
|
||||
|
||||
layer.set("id", INFTWT_PIPE_LAYER_ID);
|
||||
layer.set("lyrNm", "침입수분석 결과관로");
|
||||
layer.set("lyrTyNm", "tb_ms_swage_ppl");
|
||||
layer.set("clickPsblYn", "Y");
|
||||
layer.setVisible?.(true);
|
||||
map.addLayer(layer);
|
||||
|
||||
pipeLayerRef.current = layer;
|
||||
pipeSourceRef.current = source;
|
||||
return source;
|
||||
}, [gisMaps]);
|
||||
|
||||
const fitToRenderedFeatures = useCallback(
|
||||
(mapView?: InftwtResultMapViewResponse | null) => {
|
||||
const map = gisMaps.current;
|
||||
const zoneExtent = zoneSourceRef.current?.getExtent?.();
|
||||
const pipeExtent = pipeSourceRef.current?.getExtent?.();
|
||||
const pipeFeatures = pipeSourceRef.current?.getFeatures?.() ?? [];
|
||||
const oilqPipeExtent = getOilqPipeExtent(pipeFeatures);
|
||||
const hasPipeFeatures = pipeFeatures.length > 0;
|
||||
const hasZoneFeatures = Boolean(zoneSourceRef.current?.getFeatures?.().length);
|
||||
const preferZone = mapView?.focusTargetType === "ZONE" && hasZoneFeatures;
|
||||
const zoneExtentIsFinite = Array.isArray(zoneExtent) && zoneExtent.every((value: number) => Number.isFinite(value));
|
||||
const pipeExtentIsFinite = Array.isArray(pipeExtent) && pipeExtent.every((value: number) => Number.isFinite(value));
|
||||
const hasPipeInsideZone =
|
||||
preferZone && zoneExtentIsFinite
|
||||
? pipeFeatures.some((feature: any) => {
|
||||
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
|
||||
return Array.isArray(geometryExtent)
|
||||
&& geometryExtent.every((value: number) => Number.isFinite(value))
|
||||
&& intersectsExtent(zoneExtent as [number, number, number, number], geometryExtent as [number, number, number, number]);
|
||||
})
|
||||
: false;
|
||||
|
||||
const shouldUseZoneExtent = preferZone && (hasPipeInsideZone || !hasPipeFeatures);
|
||||
const extent = oilqPipeExtent
|
||||
? oilqPipeExtent
|
||||
: shouldUseZoneExtent
|
||||
? zoneExtent
|
||||
: hasPipeFeatures
|
||||
? pipeExtent
|
||||
: hasZoneFeatures
|
||||
? zoneExtent
|
||||
: null;
|
||||
|
||||
if (!map || !extent || extent.some((value: number) => !Number.isFinite(value))) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.getView?.().fit?.(extent, {
|
||||
duration: 350,
|
||||
padding: DEFAULT_RESULT_FIT_PADDING,
|
||||
maxZoom: oilqPipeExtent ? 17 : shouldUseZoneExtent ? 15 : 16,
|
||||
constrainResolution: true,
|
||||
nearest: true,
|
||||
});
|
||||
},
|
||||
[gisMaps],
|
||||
);
|
||||
|
||||
const moveByRegionCodes = useCallback(
|
||||
(ctpvCd?: string, sggCd?: string) => {
|
||||
const normalizedSggCd = String(sggCd ?? "").trim();
|
||||
if (normalizedSggCd && normalizedSggCd !== "0") {
|
||||
const sigunguCenter = getSigunguCenter(normalizedSggCd);
|
||||
if (sigunguCenter) {
|
||||
moveMapToCenter(gisMaps, sigunguCenter, 12);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCtpvCd = String(ctpvCd ?? "").trim();
|
||||
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
|
||||
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
|
||||
if (sidoCenterCoordinate) {
|
||||
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[gisMaps],
|
||||
);
|
||||
|
||||
const moveToStableFocus = useCallback(
|
||||
async (mapView: InftwtResultMapViewResponse) => {
|
||||
if ((mapView.oilqPipeCount ?? 0) > 0) {
|
||||
fitToRenderedFeatures(mapView);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRow = activeResultRowRef.current;
|
||||
const movedByRegion = moveByRegionCodes(
|
||||
activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd,
|
||||
activeRow?.sggCd ?? mapView.analysis?.sggCd,
|
||||
);
|
||||
if (movedByRegion) return;
|
||||
|
||||
fitToRenderedFeatures(mapView);
|
||||
},
|
||||
[fitToRenderedFeatures, moveByRegionCodes],
|
||||
);
|
||||
|
||||
const renderMapView = useCallback(
|
||||
async (mapView: InftwtResultMapViewResponse) => {
|
||||
const zoneSource = ensureZoneLayer();
|
||||
const pipeSource = ensurePipeLayer();
|
||||
if (!zoneSource || !pipeSource) return;
|
||||
|
||||
clearLayers();
|
||||
|
||||
const zoneDataProjection = resolveFeatureCollectionProjection(mapView.zoneGeometry);
|
||||
const zoneFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.zoneGeometry), {
|
||||
dataProjection: zoneDataProjection,
|
||||
featureProjection: GisApp.TO_PROJ,
|
||||
});
|
||||
zoneFeatures.forEach((feature: any) => feature.setStyle(zoneStyle));
|
||||
zoneSource.addFeatures(zoneFeatures);
|
||||
zoneSource.changed?.();
|
||||
zoneLayerRef.current?.setVisible?.(true);
|
||||
zoneLayerRef.current?.changed?.();
|
||||
|
||||
const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry);
|
||||
const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), {
|
||||
dataProjection: pipeDataProjection,
|
||||
featureProjection: GisApp.TO_PROJ,
|
||||
});
|
||||
pipeFeatures.forEach((feature: any) => {
|
||||
const hasOilq = Boolean(feature.get?.("hasOilq"));
|
||||
feature.setStyle(buildPipeStyle(hasOilq));
|
||||
});
|
||||
pipeSource.addFeatures(pipeFeatures);
|
||||
pipeSource.changed?.();
|
||||
pipeLayerRef.current?.setVisible?.(true);
|
||||
pipeLayerRef.current?.changed?.();
|
||||
gisMaps.current?.renderSync?.();
|
||||
|
||||
console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", {
|
||||
focusTargetType: mapView.focusTargetType,
|
||||
zoneFeatureCount: zoneFeatures.length,
|
||||
pipeFeatureCount: pipeFeatures.length,
|
||||
});
|
||||
|
||||
await moveToStableFocus(mapView);
|
||||
},
|
||||
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
|
||||
);
|
||||
|
||||
const handleRowSelect = useCallback(
|
||||
async (row: InftwtResultRow) => {
|
||||
try {
|
||||
activeResultRowRef.current = row;
|
||||
setFeatureData(null);
|
||||
setIsOpen(false);
|
||||
setIsSearchModalOpen(false);
|
||||
|
||||
const cachedMapView = mapViewCacheRef.current.get(row.inftwtAnlsSn);
|
||||
let mapView = cachedMapView;
|
||||
|
||||
if (!mapView) {
|
||||
const payload = await getRawJson(`/sewerage-service/api/v1/inftwtAnlsRslt/mapView/${row.inftwtAnlsSn}`);
|
||||
mapView = (payload?.data ?? payload) as InftwtResultMapViewResponse;
|
||||
mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(INFTWT_PROC_SEARCH_EVENT, {
|
||||
detail: {
|
||||
ctpvCd: row.ctpvCd ?? "0",
|
||||
sggCd: row.sggCd ?? "0",
|
||||
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
|
||||
suppressMapMove: (mapView.oilqPipeCount ?? 0) > 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setActiveMapView(mapView);
|
||||
void renderMapView(mapView);
|
||||
} catch (error) {
|
||||
console.error("移⑥엯??遺꾩꽍寃곌낵 吏???곌퀎 以??ㅻ쪟:", error);
|
||||
alert("移⑥엯??遺꾩꽍寃곌낵瑜?吏?꾩뿉 ?곌퀎?섏? 紐삵뻽?듬땲??");
|
||||
}
|
||||
},
|
||||
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
|
||||
void handleRowSelect(event.data.row as InftwtResultRow);
|
||||
};
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow };
|
||||
if (!payload?.row) return;
|
||||
void handleRowSelect(payload.row);
|
||||
} catch (error) {
|
||||
console.warn("移⑥엯??遺꾩꽍寃곌낵 storage ?숆린??媛믪쓣 ?댁꽍?섏? 紐삵뻽?듬땲??", error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handlePopupMessage);
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
return () => {
|
||||
window.removeEventListener("message", handlePopupMessage);
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
};
|
||||
}, [handleRowSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearLayers();
|
||||
};
|
||||
}, [clearLayers]);
|
||||
|
||||
const handleOverlayFeatureSelect = useCallback((data: any) => {
|
||||
if (getOverlayFlag(data) !== INFTWT_RESULT_OVERLAY_TYPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const overlayAnalysisSn = getOverlayAnalysisSn(data);
|
||||
const basePplMngNo = getOverlayBasePplMngNo(data);
|
||||
const analysisPplMngNo = getOverlayAnalysisPplMngNo(data);
|
||||
const currentRow = activeResultRowRef.current;
|
||||
const popupWindow = openWindowPopup("infilChart", {
|
||||
pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""),
|
||||
inftwtAnlsSn: overlayAnalysisSn || currentRow?.inftwtAnlsSn || 0,
|
||||
aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "",
|
||||
analysisInfo:
|
||||
getOverlayAnalysisInfo(data) ||
|
||||
currentRow?.anlsInfo ||
|
||||
activeMapView?.analysis?.anlsInfo ||
|
||||
"",
|
||||
resultOnly: "Y",
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert("?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeMapView]);
|
||||
|
||||
return {
|
||||
activeMapView,
|
||||
clearLayers,
|
||||
handleOverlayFeatureSelect,
|
||||
};
|
||||
};
|
||||
+18
-15
@@ -42,6 +42,9 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
|
||||
"materialName",
|
||||
] as const;
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
|
||||
export default function PipeAnalysisDetailPopupPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
@@ -118,7 +121,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error("관로현황 조회 중 오류:", error);
|
||||
alert("관로현황 조회 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
@@ -188,7 +191,7 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("관로현황 다운로드 중 오류:", error);
|
||||
alert("관로현황 다운로드 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -244,18 +247,18 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]);
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<main className="sn-popup-page">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">관로현황{titleSuffix}</h1>
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
|
||||
</header>
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">용도</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">용도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.useCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -273,9 +276,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관재질</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관재질</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.materialCode}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -293,9 +296,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 시작</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 시작</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.diameterFrom}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -310,9 +313,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 종료</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.diameterTo}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
@@ -32,6 +33,7 @@ const SUMMARY_FILE_PREFIX: Record<string, string> = {
|
||||
MATERIAL: "관로분석_관재질별",
|
||||
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
|
||||
};
|
||||
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
|
||||
|
||||
const toExcelColumnName = (index: number) => {
|
||||
let current = index;
|
||||
@@ -57,6 +59,9 @@ const formatGridNumber = (value: unknown) => {
|
||||
});
|
||||
};
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
|
||||
export default function PipeAnalysisPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
@@ -70,6 +75,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
|
||||
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
|
||||
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
@@ -138,10 +144,11 @@ export default function PipeAnalysisPopupPage() {
|
||||
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
|
||||
setDraftFilters(scopedFilters);
|
||||
setAppliedFilters(scopedFilters);
|
||||
setSummaryCurrentPage(1);
|
||||
await fetchSummary(scopedFilters);
|
||||
} catch (error) {
|
||||
console.error("관로분석 집계 조회 중 오류:", error);
|
||||
alert("관로분석 집계 조회 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
|
||||
setRows([]);
|
||||
setMaterialColumns([]);
|
||||
} finally {
|
||||
@@ -216,7 +223,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
return exportRow;
|
||||
});
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: exportRows as JSON[],
|
||||
data: exportRows as unknown as JSON[],
|
||||
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
|
||||
sheetNameArray: ["관로분석 현황"],
|
||||
headerRange,
|
||||
@@ -225,7 +232,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("관로분석 집계 다운로드 중 오류:", error);
|
||||
alert("관로분석 집계 다운로드 중 오류가 발생했습니다.");
|
||||
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -280,6 +287,25 @@ export default function PipeAnalysisPopupPage() {
|
||||
);
|
||||
|
||||
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
|
||||
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
|
||||
const pagedRows = useMemo(
|
||||
() =>
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? rows
|
||||
: rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
|
||||
[appliedFilters.analysisType, rows, summaryPageOffset],
|
||||
);
|
||||
const visibleYearMaterialGroups = useMemo(
|
||||
() =>
|
||||
yearMaterialGroups.slice(
|
||||
summaryPageOffset,
|
||||
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
|
||||
),
|
||||
[summaryPageOffset, yearMaterialGroups],
|
||||
);
|
||||
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? yearMaterialGroups.length
|
||||
: rows.length;
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
@@ -290,18 +316,18 @@ export default function PipeAnalysisPopupPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<main className="sn-popup-page">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">관로분석</h1>
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">관로분석</h1>
|
||||
</header>
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.ctpvCd}
|
||||
disabled={scope.ctpvLocked}
|
||||
onChange={(event) =>
|
||||
@@ -323,9 +349,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.sggCd}
|
||||
disabled={scope.sggLocked}
|
||||
onChange={(event) =>
|
||||
@@ -346,9 +372,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">처리구역</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.swtrmtZoneNm}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
@@ -366,9 +392,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">분석구분</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={draftFilters.analysisType}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
@@ -410,11 +436,8 @@ export default function PipeAnalysisPopupPage() {
|
||||
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
|
||||
<>
|
||||
<div className="card-header">
|
||||
<h3 className="hidden">
|
||||
寃쎄낵?꾨룄&?ъ쭏 愿濡쒗쁽?? 珥?{yearMaterialGroups.length.toLocaleString("ko-KR")}嫄?
|
||||
</h3>
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건
|
||||
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
|
||||
@@ -424,7 +447,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
{headList.map((head) => (
|
||||
<TableHead
|
||||
key={head.id}
|
||||
className={`border border-border bg-[#eaf3fb] px-4 py-[1.2rem] text-[1.3rem] font-medium text-[#647784] ${
|
||||
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
|
||||
head.align === "right" ? "text-right" : "text-center"
|
||||
}`}
|
||||
>
|
||||
@@ -434,8 +457,8 @@ export default function PipeAnalysisPopupPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{yearMaterialGroups.length > 0 ? (
|
||||
yearMaterialGroups.flatMap((group) =>
|
||||
{visibleYearMaterialGroups.length > 0 ? (
|
||||
visibleYearMaterialGroups.flatMap((group) =>
|
||||
group.rows.map((yearRow, rowIndex) => (
|
||||
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
|
||||
{rowIndex === 0 ? (
|
||||
@@ -467,7 +490,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
<TableCell
|
||||
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
|
||||
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
|
||||
isClickable ? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2" : ""
|
||||
isClickable ? "cursor-pointer font-semibold text-primary underline underline-offset-2" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
@@ -484,14 +507,24 @@ export default function PipeAnalysisPopupPage() {
|
||||
)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-gray-500">
|
||||
議고쉶??寃곌낵媛 ?놁뒿?덈떎.
|
||||
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-label">
|
||||
조회 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
|
||||
<div className="flex justify-center">
|
||||
<UiPageNations
|
||||
total={yearMaterialGroups.length}
|
||||
cur={summaryCurrentPage}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
onPageChange={setSummaryCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -499,21 +532,26 @@ export default function PipeAnalysisPopupPage() {
|
||||
className={appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? "hidden"
|
||||
: useHorizontalScrollTable
|
||||
? "[&>div.relative.w-full]:overflow-x-auto [&>div.relative.w-full]:overflow-y-visible"
|
||||
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
|
||||
: ""}
|
||||
headName={
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? "경과년도&재질 관로현황"
|
||||
: "관로분석현황"
|
||||
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
}
|
||||
tableData={rows}
|
||||
tableData={pagedRows}
|
||||
headlist={headList}
|
||||
pageSize={Math.max(rows.length, 1)}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
total={rows.length}
|
||||
cellClick={(index, columnId) => handleCellClick(index, columnId)}
|
||||
onPageChange={() => undefined}
|
||||
curPage={1}
|
||||
cellClick={(index, columnId) =>
|
||||
handleCellClick(
|
||||
typeof index === "number" ? index + summaryPageOffset : index,
|
||||
columnId,
|
||||
)}
|
||||
onPageChange={setSummaryCurrentPage}
|
||||
curPage={summaryCurrentPage}
|
||||
widthFixed={!useHorizontalScrollTable}
|
||||
isHideCount={true}
|
||||
cellClassName={(row, columnId) =>
|
||||
!row.__isNoZone && row.__cellFilters[columnId]
|
||||
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
|
||||
|
||||
@@ -46,6 +46,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
};
|
||||
|
||||
const { BIZ_SE_CD, BIZ_TY_CD } = useStore();
|
||||
const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim();
|
||||
const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim();
|
||||
|
||||
// 마운트 및 업데이트 시 API 호출
|
||||
|
||||
@@ -53,9 +55,18 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
if (featureData) {
|
||||
setFormData(featureData);
|
||||
}
|
||||
}, [featureData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestMngInstCd || !requestFcltMngNo) {
|
||||
setInspectionData([]);
|
||||
setCleaningData([]);
|
||||
setRepairData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchAll = async () => {
|
||||
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${featureData?.mng_inst_cd}&fcltMngNo=${featureData?.fclt_mng_no}&page=0&size=1`);
|
||||
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${requestMngInstCd}&fcltMngNo=${requestFcltMngNo}&page=0&size=1`);
|
||||
|
||||
let inspectionArr: operationDataType[] = [];
|
||||
let cleaningArr: operationDataType[] = [];
|
||||
@@ -104,8 +115,13 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
setCleaningData(cleaningArr);
|
||||
setRepairData(repairArr);
|
||||
};
|
||||
fetchAll();
|
||||
}, [featureData]);
|
||||
fetchAll().catch((error) => {
|
||||
console.error("운영 이력 조회 실패:", error);
|
||||
setInspectionData([]);
|
||||
setCleaningData([]);
|
||||
setRepairData([]);
|
||||
});
|
||||
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]);
|
||||
|
||||
// 폼 데이터가 변경될 때 호출될 핸들러
|
||||
const handleFormDataChange = (newFormData: any) => {
|
||||
@@ -160,4 +176,4 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
|
||||
)
|
||||
}
|
||||
|
||||
export default MsSwagePpl
|
||||
export default MsSwagePpl
|
||||
|
||||
@@ -33,9 +33,9 @@ const metricGroups: {
|
||||
title: string;
|
||||
field: keyof SwtrmtPrcsZoneAnalysisMetric;
|
||||
}[] = [
|
||||
{ title: "평균매설년도(년)", field: "avgInstlYear" },
|
||||
{ title: "최장매설년도(년)", field: "oldestInstlYear" },
|
||||
{ title: "최신매설년도(년)", field: "latestInstlYear" },
|
||||
{ title: "평균경과년도(년)", field: "avgInstlYear" },
|
||||
{ title: "최장경과년도(년)", field: "oldestInstlYear" },
|
||||
{ title: "최신경과년도(년)", field: "latestInstlYear" },
|
||||
];
|
||||
|
||||
const MetricVerticalGrid = ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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);
|
||||
+115
-13
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user