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

# Conflicts:
#	apps/sn/src/features/windowPopup/model/popupDefinitions.ts
#	apps/sn/src/features/windowPopup/registry.tsx
This commit is contained in:
2026-08-05 16:24:52 +09:00
55 changed files with 4173 additions and 145 deletions
@@ -17,6 +17,7 @@ type LayerComponentProps = {
chmbrYdnts: string;
gisObject?: Record<string, any>; // GIS 객체 추가
rawFeatureData?: any;
surveyResultContent?: React.ReactNode;
};
type LayerContentMap = {
[key: string]: {
@@ -0,0 +1,81 @@
"use client";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
type Props = {
jobType: ExmnMapJobType;
filters: ExmnMapFilters;
onFiltersChange: (filters: ExmnMapFilters) => void;
};
const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"];
/** 조사결과 레이어의 세 가지 자동 적용 필터다. */
export default function ExmnLayerSettingPanel({
jobType,
filters,
onFiltersChange,
}: Props) {
const updateFilter = (key: keyof ExmnMapFilters, value: string) => {
onFiltersChange({
...filters,
[key]: value.replace(/[^0-9]/g, ""),
});
};
const selectClass =
"h-[24px] w-[54px] rounded-[4px] border border-[#ccd6df] bg-white px-2 text-[1rem] text-[#222] outline-none focus:border-primary";
const labelClass = "shrink-0 text-[1.05rem] font-medium text-slate-700";
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-2 py-[9px] shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<div className="mb-2 flex items-center gap-1">
<span className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`} />
<span className="text-[1.1rem] font-semibold text-slate-700">
{jobType === "PPL" ? "관로조사" : "맨홀조사"}
</span>
</div>
<div className="space-y-1.5">
<label className="flex min-h-[25px] items-center justify-between gap-2">
<span className={labelClass}>사업차수</span>
<input
className={selectClass}
type="text"
inputMode="numeric"
maxLength={10}
placeholder="전체"
value={filters.bizCycl}
onChange={(event) => updateFilter("bizCycl", event.target.value)}
/>
</label>
<label className="flex min-h-[25px] items-center justify-between gap-2">
<span className={labelClass}>구조적 상태등급</span>
<select
className={selectClass}
value={filters.structuralGrade}
onChange={(event) => updateFilter("structuralGrade", event.target.value)}
>
{GRADE_OPTIONS.map((grade) => (
<option key={grade || "all"} value={grade}>
{grade || "전체"}
</option>
))}
</select>
</label>
<label className="flex min-h-[25px] items-center justify-between gap-2">
<span className={labelClass}>운영적 상태등급</span>
<select
className={selectClass}
value={filters.operationalGrade}
onChange={(event) => updateFilter("operationalGrade", event.target.value)}
>
{GRADE_OPTIONS.map((grade) => (
<option key={grade || "all"} value={grade}>
{grade || "전체"}
</option>
))}
</select>
</label>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
import {
Button,
Table,
TableBody,
TableCell,
TableRow,
} from "@common/components";
import { getExmnPipeReportAvailability } from "@/features/exmn";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
type ExmnSurveyResultSectionProps = {
featureData: any;
};
const readRawFeatureProperty = (featureData: any, keys: string[]) => {
for (const key of keys) {
const value =
featureData?.get?.(key) ??
featureData?.values_?.[key] ??
featureData?.[key];
if (value !== undefined && value !== null) return value;
}
return undefined;
};
const readFeatureProperty = (featureData: any, keys: string[]) =>
String(readRawFeatureProperty(featureData, keys) ?? "").trim();
const readBooleanFeatureProperty = (featureData: any, keys: string[]) => {
const value = readRawFeatureProperty(featureData, keys);
return value === true || String(value ?? "").trim().toLowerCase() === "true";
};
const displayValue = (value: string) => value || "-";
export default function ExmnSurveyResultSection({
featureData,
}: ExmnSurveyResultSectionProps) {
const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]);
const jobType = readFeatureProperty(featureData, ["jobType"]);
const pipeNumber = readFeatureProperty(featureData, [
"gisManagementNo",
"fclt_mng_no",
"fcltMngNo",
]);
const reportAvailable = readBooleanFeatureProperty(featureData, [
"reportAvailable",
]);
const canOpenPipeReport = jobType === "PPL" && Boolean(exmnBizId && pipeNumber);
const openPipeReport = async () => {
if (!canOpenPipeReport) return;
if (!reportAvailable) {
alert("등록된 관로 조사보고서가 없습니다.");
return;
}
try {
const availability = await getExmnPipeReportAvailability(
exmnBizId,
pipeNumber,
);
if (!availability.available) {
alert(
availability.message?.trim()
|| `선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
);
return;
}
const reportWindow = openWindowPopup("exmnPipeReport", {
exmnBizId,
pipeNumber,
reportNo: readFeatureProperty(featureData, ["reportNo"]),
});
if (!reportWindow) {
alert("팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요.");
}
} catch (error) {
alert(
error instanceof Error && error.message
? error.message
: "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.",
);
}
};
const rows = [
["사업명", readFeatureProperty(featureData, ["bizNm"])],
["사업차수", readFeatureProperty(featureData, ["bizCycl"])],
["처리분구", readFeatureProperty(featureData, ["trtdstNm"])],
["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"],
["보고서번호", readFeatureProperty(featureData, ["reportNo"])],
["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])],
["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])],
];
return (
<section className="border-b border-[#d9e2ec] bg-white">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl mx-3 font-medium text-label">조사 결과</h3>
{jobType === "PPL" && (
<Button
onClick={openPipeReport}
disabled={!canOpenPipeReport}
className="px-4 mr-3 py-2 bg-[#16c784] text-white rounded-sm text-sm font-medium hover:bg-[#12ad72] disabled:cursor-not-allowed disabled:bg-[#a7d9c6]"
>
조사보고서 보기
</Button>
)}
</div>
<div className="card !m-[1.2rem] !p-0">
<Table variant="secondary">
<TableBody className="text-center">
{rows.map(([label, value]) => (
<TableRow key={label}>
<TableCell className="w-[120px] whitespace-nowrap bg-blue-100 text-xs font-bold">
{label}
</TableCell>
<TableCell className="break-all text-left text-xs">
{displayValue(value)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
);
}
@@ -0,0 +1,25 @@
import { getRawJson } from "../../../../components/lib/api";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
type ExmnLayerScope = {
sggCd?: string;
ctpvNm?: string;
sggNm?: string;
};
/** 사업차수·구조등급·운영등급 AND 조건으로 조사결과 GeoJSON을 조회한다. */
export const getExmnMapLayer = async (
jobType: ExmnMapJobType,
filters: ExmnMapFilters,
scope: ExmnLayerScope,
) => {
const query = new URLSearchParams({ jobType });
if (scope.sggCd?.trim()) query.set("sggCd", scope.sggCd.trim());
if (scope.ctpvNm?.trim()) query.set("ctpvNm", scope.ctpvNm.trim());
if (scope.sggNm?.trim()) query.set("sggNm", scope.sggNm.trim());
if (filters.bizCycl) query.set("bizCycl", filters.bizCycl);
if (filters.structuralGrade) query.set("structuralGrade", filters.structuralGrade);
if (filters.operationalGrade) query.set("operationalGrade", filters.operationalGrade);
return getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`);
};
@@ -0,0 +1,298 @@
export const EXMN_RESULT_ROOT_NODE_ID = "SN_EXMN_RESULT";
export const EXMN_RESULT_PPL_NODE_ID = "SN_EXMN_RESULT_PPL";
export const EXMN_RESULT_MNHL_NODE_ID = "SN_EXMN_RESULT_MNHL";
export const EXMN_RESULT_PPL_LAYER_ID = "SN_EXMN_RESULT_PPL_LAYER";
export const EXMN_RESULT_MNHL_LAYER_ID = "SN_EXMN_RESULT_MNHL_LAYER";
export const EXMN_RESULT_ROOT_LAYER_TYPE = "sn_exmn_result_group";
export const EXMN_RESULT_PPL_LAYER_TYPE = "sn_exmn_result_ppl";
export const EXMN_RESULT_MNHL_LAYER_TYPE = "sn_exmn_result_mnhl";
// 레이어 트리에서 조사 관로와 기존 맨홀 분류 심볼을 즉시 구분하기 위한 전용 범례 스타일이다.
export const EXMN_RESULT_PPL_LEGEND_COLOR = "#facc15";
export const EXMN_RESULT_MNHL_LEGEND_ICON = "SN_EXMN_MANHOLE_PURPLE.png?v=3";
export type ExmnMapJobType = "PPL" | "MNHL";
export type ExmnMapFilters = {
bizCycl: string;
structuralGrade: string;
operationalGrade: string;
};
export type ExmnMapEntryContext = {
exmnBizId: string;
ctpvNm: string;
sggNm: string;
bizCycl: string;
jobType: ExmnMapJobType | null;
};
export const EMPTY_EXMN_MAP_FILTERS: ExmnMapFilters = {
bizCycl: "",
structuralGrade: "",
operationalGrade: "",
};
const buildControlNode = (
id: string,
name: string,
upperId: string | number | null,
layerType: string,
initialVisible: boolean,
) => {
const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE;
const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE;
return {
lyrSn: id,
id,
lyrNm: name,
upperLyrSn: upperId,
upId: upperId,
sortOrdr: upperId ? (id === EXMN_RESULT_PPL_NODE_ID ? "1" : "2") : "5",
drawOrdr: null,
flterSql: null,
gobjTy: isPipeNode ? "line" : "",
lyrTyNm: layerType,
useYn: "Y",
minViewSize: null,
iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null,
polgnColor: null,
initEprssYn: initialVisible ? "Y" : "N",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "api",
actlLyrYn: "N",
clckZoom: null,
children: null,
};
};
const resolveNodeData = (node: any) => node?.data ?? node ?? {};
const resolveNodeId = (node: any) => {
const nodeData = resolveNodeData(node);
return nodeData?.lyrSn ?? nodeData?.id ?? "";
};
const resolveNodeParentId = (node: any) => {
const nodeData = resolveNodeData(node);
return nodeData?.upperLyrSn ?? nodeData?.upId ?? "";
};
const resolveNodeLayerType = (node: any) => {
const nodeData = resolveNodeData(node);
return String(
nodeData?.lyrTyNm ??
nodeData?.lyrTypeNm ??
nodeData?.lyr_type_nm ??
"",
).trim();
};
const resolveNodeName = (node: any) => {
const nodeData = resolveNodeData(node);
return String(nodeData?.lyrNm ?? nodeData?.name ?? "").trim();
};
const sameNodeId = (left: unknown, right: unknown) =>
String(left ?? "") === String(right ?? "");
const isNodeType = (node: any, layerType: string) =>
resolveNodeLayerType(node) === layerType;
const normalizeControlNode = (
node: any,
fallbackId: string,
fallbackName: string,
upperId: string | number | null,
layerType: string,
initialVisible: boolean,
) => {
const nodeId = resolveNodeId(node) || fallbackId;
const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE;
const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE;
return {
...node,
lyrSn: nodeId,
id: nodeId,
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다.
lyrNm: fallbackName,
upperLyrSn: upperId,
upId: upperId,
lyrTyNm: layerType,
gobjTy: isPipeNode ? "line" : "",
iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null,
lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null,
initEprssYn: initialVisible ? "Y" : "N",
clickPsblYn: "N",
srvcNm: "api",
actlLyrYn: "N",
};
};
/**
* 서버 레이어 설정의 조사결과 노드를 우선 사용한다.
* DB 설정이 아직 배포되지 않았거나 일부만 등록된 환경에서는 누락 노드만 가상 노드로 보완한다.
*/
export const appendExmnResultControlNodes = (
data: unknown[],
initialJobType: ExmnMapJobType | null,
) => {
if (!Array.isArray(data)) return data;
const rootNode =
data.find((item: any) => isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE)) ??
data.find((item: any) =>
sameNodeId(resolveNodeId(item), EXMN_RESULT_ROOT_NODE_ID),
) ??
data.find(
(item: any) =>
resolveNodeName(item) === "조사결과" && !resolveNodeParentId(item),
);
const rootId = resolveNodeId(rootNode) || EXMN_RESULT_ROOT_NODE_ID;
const pplNode =
data.find((item: any) => isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE)) ??
data.find((item: any) =>
sameNodeId(resolveNodeId(item), EXMN_RESULT_PPL_NODE_ID),
) ??
data.find(
(item: any) =>
["관로조사", "CCTV조사"].includes(resolveNodeName(item)) &&
sameNodeId(resolveNodeParentId(item), rootId),
);
const mnhlNode =
data.find((item: any) => isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE)) ??
data.find((item: any) =>
sameNodeId(resolveNodeId(item), EXMN_RESULT_MNHL_NODE_ID),
) ??
data.find(
(item: any) =>
resolveNodeName(item) === "맨홀조사" &&
sameNodeId(resolveNodeParentId(item), rootId),
);
const normalizedPplNode = pplNode
? normalizeControlNode(
pplNode,
EXMN_RESULT_PPL_NODE_ID,
"관로조사",
rootId,
EXMN_RESULT_PPL_LAYER_TYPE,
initialJobType === "PPL",
)
: buildControlNode(
EXMN_RESULT_PPL_NODE_ID,
"관로조사",
rootId,
EXMN_RESULT_PPL_LAYER_TYPE,
initialJobType === "PPL",
);
const normalizedMnhlNode = mnhlNode
? normalizeControlNode(
mnhlNode,
EXMN_RESULT_MNHL_NODE_ID,
"맨홀조사",
rootId,
EXMN_RESULT_MNHL_LAYER_TYPE,
initialJobType === "MNHL",
)
: buildControlNode(
EXMN_RESULT_MNHL_NODE_ID,
"맨홀조사",
rootId,
EXMN_RESULT_MNHL_LAYER_TYPE,
initialJobType === "MNHL",
);
const childIds = [
resolveNodeId(normalizedPplNode),
resolveNodeId(normalizedMnhlNode),
];
const previousChildren = Array.isArray((rootNode as any)?.children)
? (rootNode as any).children
: [];
const normalizedRootNode = {
...(rootNode
? normalizeControlNode(
rootNode,
EXMN_RESULT_ROOT_NODE_ID,
"조사결과",
null,
EXMN_RESULT_ROOT_LAYER_TYPE,
false,
)
: buildControlNode(
EXMN_RESULT_ROOT_NODE_ID,
"조사결과",
null,
EXMN_RESULT_ROOT_LAYER_TYPE,
false,
)),
children: [...previousChildren, ...childIds].filter(
(childId, index, children) =>
childId !== "" &&
children.findIndex((candidate) => sameNodeId(candidate, childId)) ===
index,
),
};
const recognizedIds = new Set(
[
EXMN_RESULT_ROOT_NODE_ID,
EXMN_RESULT_PPL_NODE_ID,
EXMN_RESULT_MNHL_NODE_ID,
resolveNodeId(rootNode),
resolveNodeId(pplNode),
resolveNodeId(mnhlNode),
]
.filter((id) => id !== "")
.map((id) => String(id)),
);
const filtered = data.filter((item: any) => {
const nodeId = String(resolveNodeId(item));
return (
!recognizedIds.has(nodeId) &&
!isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE) &&
!isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE) &&
!isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE)
);
});
return [
...filtered,
normalizedRootNode,
normalizedPplNode,
normalizedMnhlNode,
];
};
export const isExmnResultPplNode = (node: any) =>
sameNodeId(resolveNodeId(node), EXMN_RESULT_PPL_NODE_ID) ||
isNodeType(node, EXMN_RESULT_PPL_LAYER_TYPE);
export const isExmnResultMnhlNode = (node: any) =>
sameNodeId(resolveNodeId(node), EXMN_RESULT_MNHL_NODE_ID) ||
isNodeType(node, EXMN_RESULT_MNHL_LAYER_TYPE);
export const buildExmnMapEntryContext = (
searchParams: Pick<URLSearchParams, "get">,
): ExmnMapEntryContext => {
const rawJobType = String(searchParams.get("exmnJobType") ?? "").toUpperCase();
return {
exmnBizId: String(searchParams.get("exmnBizId") ?? "").trim(),
ctpvNm: String(searchParams.get("ctpvNm") ?? "").trim(),
sggNm: String(searchParams.get("sggNm") ?? "").trim(),
bizCycl: String(searchParams.get("bizCycl") ?? "").replace(/[^0-9]/g, ""),
jobType: rawJobType === "PPL" || rawJobType === "MNHL" ? rawJobType : null,
};
};
@@ -0,0 +1,333 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { UiTable } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
import {
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
FLOOD_DNGR_MAP_DEPTH_OPTIONS,
FLOOD_DNGR_MAP_FREQUENCY_OPTIONS,
FLOOD_DNGR_MAP_HEADLIST,
FLOOD_DNGR_MAP_PAGE_SIZE,
buildFloodDngrMapListQueryString,
toFloodDngrMapTableRows,
type FloodDngrMapApiItem,
type FloodDngrMapExtent,
type FloodDngrMapFilters,
type FloodDngrMapTableRow,
} from "./floodDngrMapTypes";
const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000;
const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000;
/** Abort 처리 지연 여부와 무관하게 제한 시간이 지나면 팝업 요청 Promise를 종료한다. */
const getFloodDngrMapJson = async (url: string) => {
let timeoutId: number | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(new Error("도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요."));
}, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS);
});
try {
return await Promise.race([getData(url), timeoutPromise]);
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
}
};
/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */
export default function FloodDngrMapPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<FloodDngrMapFilters>(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<FloodDngrMapFilters>(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
const [items, setItems] = useState<FloodDngrMapApiItem[]>([]);
const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState<string[] | null>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [sort, setSort] = useState<UiTableSort>();
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const availableCtpvCodes = useMemo(
() => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 2)).filter(Boolean)),
[administrativeScopeCodes],
);
const availableSggCodes = useMemo(
() => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 5)).filter(Boolean)),
[administrativeScopeCodes],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeCodes === null
? ctpv
: ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))),
[administrativeScopeCodes, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(() => {
const scopedOptions =
administrativeScopeCodes === null
? sgg
: sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5)));
return filters.ctpvCd === "0"
? scopedOptions
: scopedOptions.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
}, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]);
const rows = useMemo(
() =>
toFloodDngrMapTableRows(
items,
currentPage - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
ctpv,
sgg,
total,
sort,
),
[ctpv, currentPage, items, sgg, sort, total],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 요청 Promise가 예상과 다르게 정리되지 않아도 로딩 오버레이를 최대 12초 안에 해제한다. */
useEffect(() => {
if (!isLoading) return;
const watchdogId = window.setTimeout(() => {
setIsLoading(false);
setErrorMessage((current) =>
current || "도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.",
);
}, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS);
return () => window.clearTimeout(watchdogId);
}, [isLoading]);
/** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: FloodDngrMapFilters, nextSort?: UiTableSort) => {
setIsLoading(true);
setErrorMessage("");
try {
const queryString = buildFloodDngrMapListQueryString(
nextFilters,
page - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
nextSort,
);
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`,
);
const payload = response as {
items?: FloodDngrMapApiItem[];
total?: number;
} | null;
if (!payload || !Array.isArray(payload.items)) {
throw new Error("도시침수지도 목록 API 응답 형식이 올바르지 않습니다.");
}
setItems(payload.items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
setSort(nextSort);
setAppliedSort(nextSort);
setSelectedIndex(-1);
} catch (error) {
console.error("도시침수지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
setErrorMessage(
error instanceof Error ? error.message : "도시침수지도 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[],
);
/** 실제 도시침수지도 데이터가 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const response = await getFloodDngrMapJson(
"/sewerage-service/api/v1/fldng/flood-dngr-map/scope",
);
if (!Array.isArray(response)) {
throw new Error("도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다.");
}
setAdministrativeScopeCodes(response);
} catch (error) {
console.error("도시침수지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeCodes(null);
}
}, []);
useEffect(() => {
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
const handleSearch = () => {
void loadRows(1, filters, sort);
};
/** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */
const handleRowClick = async (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row?.sggCd || !row?.sbmrgFrqExpln) return;
setSelectedIndex(index);
setIsLoading(true);
setErrorMessage("");
try {
const params = new URLSearchParams({
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
});
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const extent = [payload?.minX, payload?.minY, payload?.maxX, payload?.maxY].map(Number);
if (extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) {
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
}
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
payload: {
extent,
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
},
},
window.location.origin,
);
} catch (error) {
console.error("도시침수지도 위치 이동 중 오류:", error);
const message =
error instanceof Error && error.message.includes("404")
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
: error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
setErrorMessage(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 border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">도시침수지도 목록</h1>
</header>
<section className="flex min-h-0 flex-1 flex-col p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[200px_200px_220px_200px_auto] md:items-end">
<label className="block">
<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>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>{item.text}</option>
))}
</select>
</label>
<label className="block">
<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">
<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.sbmrgDpthlCd}
onChange={(event) => setFilters({ ...filters, sbmrgDpthlCd: event.target.value })}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_DEPTH_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<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.sbmrgFrqExpln}
onChange={(event) => setFilters({ ...filters, sbmrgFrqExpln: event.target.value })}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={value}>{Number(value)}년</option>
))}
</select>
</label>
<div className="flex justify-end md:pr-3">
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={handleSearch}>
조회
</Button>
</div>
</div>
{errorMessage ? (
<div role="alert" className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700">
{errorMessage}
</div>
) : null}
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<FloodDngrMapTableRow>
headName={
<>
도시침수지도 조회 결과 총 {total.toLocaleString()}건
</>
}
tableData={rows}
headlist={FLOOD_DNGR_MAP_HEADLIST}
pageSize={FLOOD_DNGR_MAP_PAGE_SIZE}
total={total}
cellClick={(index) => void handleRowClick(index)}
onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
sort={sort}
onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)}
/>
</div>
</section>
</main>
);
}
@@ -0,0 +1,172 @@
"use client";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)";
export const FLOOD_DNGR_MAP_PAGE_SIZE = 20;
export type FloodDngrMapFilters = {
ctpvCd: string;
sggCd: string;
sbmrgDpthlCd: string;
sbmrgFrqExpln: string;
};
export type FloodDngrMapApiItem = {
ctpvCd: string;
sggCd: string;
minSbmrgDpthlCd: string;
maxSbmrgDpthlCd: string;
sbmrgFrqExpln: string;
};
export type FloodDngrMapExtent = {
minX: number;
minY: number;
maxX: number;
maxY: number;
};
export type FloodDngrMapTableRow = FloodDngrMapApiItem & {
rowNo: number;
ctpvNm: string;
sggNm: string;
sbmrgDpthlDisplay: string;
sbmrgFrqDisplay: string;
};
type AdministrativeOption = {
val: unknown;
text: unknown;
};
export const FLOOD_DNGR_MAP_DEFAULT_FILTERS: FloodDngrMapFilters = {
ctpvCd: "0",
sggCd: "0",
sbmrgDpthlCd: "0",
sbmrgFrqExpln: "0",
};
export const FLOOD_DNGR_MAP_FREQUENCY_OPTIONS = ["030", "050", "080", "100"];
export const FLOOD_DNGR_MAP_DEPTH_LABELS: Record<string, string> = {
N330: "0.0~0.5m 미만",
N331: "0.5~1.0m 미만",
N332: "1.0~2.0m 미만",
N333: "2.0~5.0m 미만",
N334: "5.0m 이상",
};
export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries(FLOOD_DNGR_MAP_DEPTH_LABELS).map(
([value, label]) => ({ value, label }),
);
export const FLOOD_DNGR_MAP_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 8, sortable: true, sortKey: "rowNo" },
{ id: "ctpvNm", title: "시도", widthPercent: 18, sortable: true, sortKey: "ctpvCd" },
{ id: "sggNm", title: "시군구", widthPercent: 20, sortable: true, sortKey: "sggCd" },
{
id: "sbmrgDpthlDisplay",
title: "침수심도(최소~최대)",
widthPercent: 31,
sortable: true,
sortKey: "sbmrgDpthlCd",
},
{
id: "sbmrgFrqDisplay",
title: "침수빈도",
widthPercent: 23,
sortable: true,
sortKey: "sbmrgFrqExpln",
},
];
/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */
export const buildFloodDngrMapListQueryString = (
filters: FloodDngrMapFilters,
page: number,
size: number,
sort?: UiTableSort,
) => {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") {
params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd);
}
if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") {
params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln);
}
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
return params.toString();
};
const resolveAdministrativeName = (
value: unknown,
options: AdministrativeOption[],
codeLength: number,
) => {
const code = String(value ?? "").trim().slice(0, codeLength);
const matched = options.find(
(option) => String(option.val ?? "").trim().slice(0, codeLength) === code,
);
return String(matched?.text ?? "").trim() || "-";
};
const toFrequencyDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "-";
const numericValue = Number(normalized);
return Number.isFinite(numericValue) ? `${numericValue}년` : normalized;
};
const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record<string, { min: string; max: string; openEnded?: boolean }> = {
N330: { min: "0.0", max: "0.5" },
N331: { min: "0.5", max: "1.0" },
N332: { min: "1.0", max: "2.0" },
N333: { min: "2.0", max: "5.0" },
N334: { min: "5.0", max: "5.0", openEnded: true },
};
/** 그룹의 최소·최대 침수심도 코드를 하나의 범위 문구로 표시한다. */
const toDepthRangeDisplay = (minCode: string, maxCode: string) => {
if (!minCode && !maxCode) return "-";
if (minCode === maxCode) return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-";
const minDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[minCode];
const maxDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[maxCode];
if (!minDepth || !maxDepth) return `${minCode || "-"} ~ ${maxCode || "-"}`;
return maxDepth.openEnded
? `${minDepth.min}m ~ ${maxDepth.min}m 이상`
: `${minDepth.min}m ~ ${maxDepth.max}m 미만`;
};
/** 서버 페이지 번호와 코드 표시값을 화면 행으로 변환한다. */
export const toFloodDngrMapTableRows = (
items: FloodDngrMapApiItem[],
page: number,
pageSize = FLOOD_DNGR_MAP_PAGE_SIZE,
ctpvOptions: AdministrativeOption[] = [],
sggOptions: AdministrativeOption[] = [],
total = items.length,
sort?: UiTableSort,
): FloodDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo:
sort?.key === "rowNo" && sort.direction === "desc"
? total - page * pageSize - index
: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
sbmrgDpthlDisplay: toDepthRangeDisplay(item.minSbmrgDpthlCd, item.maxSbmrgDpthlCd),
sbmrgFrqDisplay: toFrequencyDisplay(item.sbmrgFrqExpln),
}));
const resolveNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
/** 빈도·침수심 자식 노드가 아니라 도시침수지도 루트만 목록보기 대상으로 판별한다. */
export const isFloodDngrMapRootNode = (node: any) =>
resolveNodeName(node).replace(/\s/g, "") === FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
@@ -34,7 +34,15 @@ const inputColumn = [
type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string };
const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const MsSwagePpl = ({
featureData,
onSaveClick,
surveyResultContent,
}: {
featureData: any;
onSaveClick?: () => void;
surveyResultContent?: React.ReactNode;
}) => {
const [formData, setFormData] = useState<any>({});
const [repairData, setRepairData] = useState<operationDataType[]>([]);
const [cleaningData, setCleaningData] = useState<operationDataType[]>([]);
@@ -48,6 +56,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();
// 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
const showOperationSections = !surveyResultContent;
// 마운트 및 업데이트 시 API 호출
@@ -58,7 +68,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
}, [featureData]);
useEffect(() => {
if (!requestMngInstCd || !requestFcltMngNo) {
if (!showOperationSections || !requestMngInstCd || !requestFcltMngNo) {
setInspectionData([]);
setCleaningData([]);
setRepairData([]);
@@ -121,7 +131,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
setCleaningData([]);
setRepairData([]);
});
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]);
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd, showOperationSections]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
@@ -154,24 +164,29 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
<InputSection
title="보수 및 갱신"
data={repairData}
sectionKey="repair"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="청소 및 준설"
data={cleaningData}
sectionKey="cleaning"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="점검 및 진단"
data={inspectionData}
sectionKey="inspection"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
{surveyResultContent}
{showOperationSections && (
<>
<InputSection
title="보수 및 갱신"
data={repairData}
sectionKey="repair"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="청소 및 준설"
data={cleaningData}
sectionKey="cleaning"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="점검 및 진단"
data={inspectionData}
sectionKey="inspection"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
</>
)}
</>
)
}
@@ -49,6 +49,7 @@ import { searchUpperPpls } from "./smh";
import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import useStore from "@/store/useStore";
import ExmnSurveyResultSection from "../exmn/ExmnSurveyResultSection";
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
@@ -121,7 +122,15 @@ type MngInstOption = {
lwrInstNm: string;
};
const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => {
const FeatureClickEventPopup = ({
onClose,
featureData,
surveyMode = false,
}: {
onClose: () => void;
featureData: any;
surveyMode?: boolean;
}) => {
const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
const [layerSpecificNm, setLayerSpecificNm] = useState<string>(""); // 레이어 이름 상태
@@ -774,7 +783,18 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
<LoadingOverlay isLoading={isLoading} />
<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">{layerNm}
{surveyMode && (
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
조사관리 <br />
관로번호 : {
featureData?.get?.("gisManagementNo")
?? featureData?.values_?.gisManagementNo
?? featureData?.gisManagementNo
?? layerSpecificNm
}
</h3>
)}
<h3 className={`${surveyMode ? "hidden" : ""} text-2xl font-medium text-white text-label whitespace-nowrap`}>{layerNm}
{tableData?.usg_cd && (
<>
({tableData.usg_cd})
@@ -872,6 +892,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
chmbrYdnts={chmbrYdnts}
gisObject={featureData?.values_ ?? featureData}
rawFeatureData={featureData}
surveyResultContent={
surveyMode
? <ExmnSurveyResultSection featureData={featureData} />
: undefined
}
/>
}
</div>
@@ -13,7 +13,7 @@
* - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service)
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import { useEffect, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getData } from '@/components/lib/api';
@@ -80,7 +80,15 @@ const inputColumn = [
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] },
];
const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const Smh = ({
featureData,
onSaveClick,
surveyResultContent,
}: {
featureData: any;
onSaveClick?: () => void;
surveyResultContent?: ReactNode;
}) => {
const [formData, setFormData] = useState<any>({});
// featureData가 변경될 때 formData 초기화
@@ -104,6 +112,7 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
};
return (
<>
<VerticalTable
headName='입력정보'
tableData={featureData ? [formData] : []}
@@ -120,6 +129,8 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
{surveyResultContent}
</>
)
}
@@ -0,0 +1,267 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { UiTable } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
import {
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
SBMRG_DNGR_MAP_FREQUENCY_OPTIONS,
SBMRG_DNGR_MAP_HEADLIST,
SBMRG_DNGR_MAP_LAYER_TYPE,
SBMRG_DNGR_MAP_PAGE_SIZE,
SBMRG_DNGR_MAP_SCOPE_SIZE,
buildSbmrgDngrMapListQueryString,
extractAdministrativeCodes,
toSbmrgDngrMapTableRows,
type SbmrgDngrMapApiItem,
type SbmrgDngrMapFilters,
type SbmrgDngrMapTableRow,
} from "./sbmrgDngrMapTypes";
const unwrapPayload = <T,>(response: any): T => (response?.data ?? response) as T;
/** 시설물 레이어의 중점관리지역 침수위험지도 피처를 조건별 목록으로 제공한다. */
export default function SbmrgDngrMapPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 입력 중인 조건과 실제 조회에 적용된 조건을 분리해 페이지 이동 시 조건이 흔들리지 않게 한다.
const [filters, setFilters] = useState<SbmrgDngrMapFilters>(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<SbmrgDngrMapFilters>(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
const [items, setItems] = useState<SbmrgDngrMapApiItem[]>([]);
const [administrativeScopeItems, setAdministrativeScopeItems] = useState<SbmrgDngrMapApiItem[] | null>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [sort, setSort] = useState<UiTableSort>();
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
const availableCtpvCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.ctpvCd, 2),
),
),
[administrativeScopeItems],
);
const availableSggCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.sggCd, 5),
),
),
[administrativeScopeItems],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeItems === null
? ctpv
: ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))),
[administrativeScopeItems, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(
() => {
const scopedOptions =
administrativeScopeItems === null
? sgg
: sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5)));
return filters.ctpvCd === "0"
? scopedOptions
: scopedOptions.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
);
},
[administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg],
);
const rows = useMemo(
() =>
toSbmrgDngrMapTableRows(
items,
currentPage - 1,
SBMRG_DNGR_MAP_PAGE_SIZE,
ctpv,
sgg,
total,
sort,
),
[ctpv, currentPage, items, sgg, sort, total],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: SbmrgDngrMapFilters, nextSort?: UiTableSort) => {
setIsLoading(true);
try {
const queryString = buildSbmrgDngrMapListQueryString(
nextFilters,
page - 1,
SBMRG_DNGR_MAP_PAGE_SIZE,
nextSort,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[]; total?: number }>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
setItems(items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
setSort(nextSort);
setAppliedSort(nextSort);
setSelectedIndex(-1);
} catch (error) {
console.error("침수위험지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
alert(error instanceof Error ? error.message : "침수위험지도 목록 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[],
);
/** 전체 50개 그룹에서 실제 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const queryString = buildSbmrgDngrMapListQueryString(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
0,
SBMRG_DNGR_MAP_SCOPE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(response);
setAdministrativeScopeItems(Array.isArray(payload?.items) ? payload.items : []);
} catch (error) {
console.error("침수위험지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeItems(null);
}
}, []);
useEffect(() => {
void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
/** 현재 입력 조건을 적용하고 첫 페이지부터 다시 조회한다. */
const handleSearch = () => {
void loadRows(1, filters, sort);
};
/** 선택한 지역·빈도 그룹의 전체 fid를 부모 지도에 전달해 전체 범위 이동·강조를 실행한다. */
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!Array.isArray(row?.fids) || row.fids.length === 0) return;
setSelectedIndex(index);
const featureIds = row.fids.map((fid) =>
String(fid).startsWith(`${SBMRG_DNGR_MAP_LAYER_TYPE}.`)
? String(fid)
: `${SBMRG_DNGR_MAP_LAYER_TYPE}.${fid}`,
);
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
payload: { layerNm: SBMRG_DNGR_MAP_LAYER_TYPE, fids: JSON.stringify(featureIds) },
},
window.location.origin,
);
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">침수위험지도 목록</h1>
</header>
<section className="flex min-h-0 flex-1 flex-col p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[220px_220px_220px_auto] md:items-end">
<label className="block">
<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>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>{item.text}</option>
))}
</select>
</label>
<label className="block">
<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">
<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.floodWtlvFrqVl}
onChange={(event) => setFilters({ ...filters, floodWtlvFrqVl: event.target.value })}
>
<option value="0">전체</option>
{SBMRG_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={String(value)}>{value}년</option>
))}
</select>
</label>
<div className="flex justify-end md:pr-3">
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={handleSearch}>
조회
</Button>
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<SbmrgDngrMapTableRow>
headName={<>침수위험지도 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={SBMRG_DNGR_MAP_HEADLIST}
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
sort={sort}
onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)}
/>
</div>
</section>
</main>
);
}
@@ -0,0 +1,153 @@
"use client";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)";
export const SBMRG_DNGR_MAP_PAGE_SIZE = 20;
export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000;
export type SbmrgDngrMapFilters = {
ctpvCd: string;
sggCd: string;
floodWtlvFrqVl: string;
};
export type SbmrgDngrMapApiItem = {
fids: string[];
ctpvCd: string;
sggCd: string;
emphsMngRgnNm: string;
emphsMngRgnMngNo: string;
floodWtlvFrqVl: number | string | null;
minElvtVl: number | string | null;
maxElvtVl: number | string | null;
};
export type SbmrgDngrMapTableRow = SbmrgDngrMapApiItem & {
rowNo: number;
ctpvNm: string;
sggNm: string;
floodWtlvFrqVlDisplay: string;
elvtVlDisplay: string;
};
type AdministrativeOption = {
val: unknown;
text: unknown;
};
export const SBMRG_DNGR_MAP_DEFAULT_FILTERS: SbmrgDngrMapFilters = {
ctpvCd: "0",
sggCd: "0",
floodWtlvFrqVl: "0",
};
export const SBMRG_DNGR_MAP_FREQUENCY_OPTIONS = [30, 50, 80, 100];
export const SBMRG_DNGR_MAP_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 7, sortable: true, sortKey: "rowNo" },
{ id: "ctpvNm", title: "시도", widthPercent: 11, sortable: true, sortKey: "ctpvCd" },
{ id: "sggNm", title: "시군구", widthPercent: 12, sortable: true, sortKey: "sggCd" },
{ id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23, sortable: true },
{ id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15, sortable: true },
{
id: "floodWtlvFrqVlDisplay",
title: "홍수수위빈도값",
widthPercent: 17,
sortable: true,
sortKey: "floodWtlvFrqVl",
},
{
id: "elvtVlDisplay",
title: "해발값(최소~최대)",
widthPercent: 15,
sortable: true,
sortKey: "elvtVl",
},
];
/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */
export const buildSbmrgDngrMapListQueryString = (
filters: SbmrgDngrMapFilters,
page: number,
size: number,
sort?: UiTableSort,
) => {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
if (filters.floodWtlvFrqVl && filters.floodWtlvFrqVl !== "0") {
params.set("floodWtlvFrqVl", filters.floodWtlvFrqVl);
}
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
return params.toString();
};
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized || "-";
};
const toRangeDisplay = (minValue: unknown, maxValue: unknown) => {
const minDisplay = toDisplay(minValue);
const maxDisplay = toDisplay(maxValue);
if (minDisplay === "-" && maxDisplay === "-") return "-";
if (minDisplay === maxDisplay) return minDisplay;
return `${minDisplay} ~ ${maxDisplay}`;
};
/** 집계 응답에서 쉼표로 묶일 수 있는 행정구역 코드를 정규화한다. */
export const extractAdministrativeCodes = (value: unknown, codeLength: number) =>
String(value ?? "")
.split(",")
.map((code) => code.trim().slice(0, codeLength))
.filter(Boolean);
/** 쉼표로 묶인 행정구역 코드도 각각 한글명으로 변환하고 중복 명칭은 제거한다. */
const resolveAdministrativeNames = (
value: unknown,
options: AdministrativeOption[],
codeLength: number,
) => {
const names = extractAdministrativeCodes(value, codeLength)
.map((code) => {
const matched = options.find(
(option) => String(option.val ?? "").trim().slice(0, codeLength) === code,
);
return String(matched?.text ?? "").trim();
})
.filter(Boolean);
return names.length > 0 ? [...new Set(names)].join(", ") : "-";
};
/** 서버 페이지 번호를 화면 NO에 반영하고 숫자 표시값을 보정한다. */
export const toSbmrgDngrMapTableRows = (
items: SbmrgDngrMapApiItem[],
page: number,
pageSize = SBMRG_DNGR_MAP_PAGE_SIZE,
ctpvOptions: AdministrativeOption[] = [],
sggOptions: AdministrativeOption[] = [],
total = items.length,
sort?: UiTableSort,
): SbmrgDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo:
sort?.key === "rowNo" && sort.direction === "desc"
? total - page * pageSize - index
: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
elvtVlDisplay: toRangeDisplay(item.minElvtVl, item.maxElvtVl),
}));
const resolveNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
/** 빈도·침수심 자식 노드가 아니라 중점관리지역 위험지도 루트만 목록보기 대상으로 판별한다. */
export const isSbmrgDngrMapRootNode = (node: any) =>
resolveNodeName(node).replace(/\s/g, "") === SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
+538 -12
View File
@@ -18,7 +18,16 @@
"use client";
import dynamic from "next/dynamic";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type UIEvent,
} from "react";
import { usePathname, useSearchParams } from "next/navigation";
import Collection from "ol/Collection.js";
@@ -29,6 +38,7 @@ import Draw from "ol/interaction/Draw.js";
import Snap from "ol/interaction/Snap.js";
import Translate from "ol/interaction/Translate.js";
import Fill from "ol/style/Fill.js";
import Icon from "ol/style/Icon.js";
import LineString from "ol/geom/LineString.js";
import Point from "ol/geom/Point.js";
import Polygon from "ol/geom/Polygon.js";
@@ -84,6 +94,19 @@ import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel";
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
import {
EMPTY_EXMN_MAP_FILTERS,
EXMN_RESULT_MNHL_LAYER_ID,
EXMN_RESULT_PPL_LAYER_ID,
appendExmnResultControlNodes,
buildExmnMapEntryContext,
isExmnResultMnhlNode,
isExmnResultPplNode,
type ExmnMapFilters,
type ExmnMapJobType,
} from "./components/exmn/exmnMapTypes";
import {
buildSinkholeDefaultYearRange,
buildSinkholeFiltersFromYearRange,
@@ -120,6 +143,15 @@ import {
isFldngHstryNode,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
import {
SBMRG_DNGR_MAP_POPUP_KEY,
isSbmrgDngrMapRootNode,
} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes";
import {
FLOOD_DNGR_MAP_LAYER_TYPE,
FLOOD_DNGR_MAP_POPUP_KEY,
isFloodDngrMapRootNode,
} from "./components/floodDngrMap/floodDngrMapTypes";
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
import SearchModal from "./searchModal/searchModal";
import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "./waterDepth/waterDepth";
@@ -146,6 +178,7 @@ const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
const SINKHOLE_DEFAULT_POINT_LIMIT = 2000;
const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다.
const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png";
const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
@@ -155,6 +188,40 @@ const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
2500000,
];
const EXMN_GRADE_COLORS: Record<number, string> = {
1: "#22c55e",
2: "#84cc16",
3: "#facc15",
4: "#f97316",
5: "#dc2626",
};
const EXMN_RESULT_MANHOLE_ICON_SRC =
"/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3";
const getExmnGradeColor = (feature: any) => {
const grade = Number(feature?.get?.("structuralGrade") ?? 0);
return EXMN_GRADE_COLORS[grade] ?? "#2563eb";
};
const createExmnPipeStyle = (feature: any) =>
new Style({
stroke: new Stroke({
color: getExmnGradeColor(feature),
width: 4,
}),
});
// 레이어 트리 범례와 지도 피처가 동일한 기존 맨홀 형태의 보라색 심볼을 사용한다.
const createExmnManholeStyle = () =>
new Style({
image: new Icon({
src: EXMN_RESULT_MANHOLE_ICON_SRC,
anchor: [0.5, 0.5],
anchorXUnits: "fraction",
anchorYUnits: "fraction",
}),
});
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction";
const FLOW_DIRECTION_LAYER_NAME = "유하방향";
@@ -179,6 +246,7 @@ type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
type RightPanelTabCategory =
| "sinkhole"
| "pipe"
| "exmnSurvey"
| "treatmentFacility"
| "treatmentZone"
| "defaultFeature"
@@ -189,6 +257,7 @@ type RightPanelTab = {
kind: RightPanelTabKind;
category: RightPanelTabCategory;
displayLabel: string;
surveyJobType?: ExmnMapJobType;
parentTabId?: string | null;
featureData: any;
sinkholeDetail?: SinkholeDetail | null;
@@ -222,6 +291,10 @@ const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
active: "border-[#2563eb] bg-[#2563eb] text-white",
inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]",
},
exmnSurvey: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
treatmentFacility: {
active: "border-[#15803d] bg-[#15803d] text-white",
inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]",
@@ -240,6 +313,20 @@ const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
},
};
const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record<
ExmnMapJobType,
RightPanelTabStyle
> = {
PPL: {
active: "border-[#eab308] bg-[#facc15] text-[#713f12]",
inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]",
},
MNHL: {
active: "border-[#8b5cf6] bg-[#8b5cf6] text-white",
inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]",
},
};
// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다.
const findRightPanelTabOverflowCandidate = (
tabs: RightPanelTab[],
@@ -267,7 +354,10 @@ const RightPanelBookmarkTabs = ({
<div className="pointer-events-auto absolute right-[350px] top-[92px] z-[70] flex w-[112px] flex-col items-end gap-2">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const style = RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
const style =
tab.category === "exmnSurvey" && tab.surveyJobType
? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType]
: RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
const parentTab = tab.parentTabId
? tabs.find((candidate) => candidate.id === tab.parentTabId)
: null;
@@ -335,6 +425,7 @@ const getLayerTypeName = (layerInfo: any) =>
layerInfo?.lyr_type_nm ??
"",
).trim();
const getLayerFilterSql = (layerInfo: any) =>
String(
layerInfo?.filtrSql ??
@@ -923,6 +1014,8 @@ const getRightPanelFeatureProperty = (data: any, keys: string[]) => {
};
const getRightPanelTabCategory = (data: any): RightPanelTabCategory => {
const surveyOverlayType = getRightPanelFeatureProperty(data, ["surveyOverlayType"]);
if (surveyOverlayType === "EXMN_RESULT") return "exmnSurvey";
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName === "tb_ms_swage_ppl") return "pipe";
if (layerTypeName === "tb_il_swage_prcs_fclt") return "treatmentFacility";
@@ -931,6 +1024,16 @@ const getRightPanelTabCategory = (data: any): RightPanelTabCategory => {
};
const getRightPanelFeatureDisplayLabel = (data: any, category: RightPanelTabCategory) => {
if (category === "exmnSurvey") {
const managementNo =
getRightPanelFeatureProperty(data, ["gisManagementNo", "fclt_mng_no", "fcltMngNo"]) ||
"-";
const jobType = getRightPanelFeatureProperty(data, ["jobType"]);
const surveyLabel =
jobType === "PPL" ? "관로조사" : jobType === "MNHL" ? "맨홀조사" : "조사관리";
return `${surveyLabel}(${managementNo})`;
}
if (category === "pipe") {
const managementNo =
getRightPanelFeatureProperty(data, ["fclt_mng_no", "fcltMngNo", "pipeId", "fid"]) ||
@@ -2465,6 +2568,10 @@ const appendSinkholeControlNode = (data: any[]) => {
const Map = () => {
const searchParams = useSearchParams();
const pathname = usePathname();
const exmnMapEntryContext = useMemo(
() => buildExmnMapEntryContext(searchParams),
[searchParams],
);
const initialAnalysisPopupKey = useMemo(() => {
const popupKey = searchParams.get("analysisPopupKey");
if (!popupKey || !isWindowPopupKey(popupKey)) return null;
@@ -2493,6 +2600,16 @@ const Map = () => {
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>(() => ({
...EMPTY_EXMN_MAP_FILTERS,
bizCycl: exmnMapEntryContext.bizCycl,
}));
const [exmnMnhlFilters, setExmnMnhlFilters] = useState<ExmnMapFilters>(() => ({
...EMPTY_EXMN_MAP_FILTERS,
bizCycl: exmnMapEntryContext.bizCycl,
}));
const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] = useState<SwagePrcsFcltLayerMode>("all");
const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState<string[]>([]);
const [draftAgedPipeFilters, setDraftAgedPipeFilters] =
@@ -2522,6 +2639,30 @@ const Map = () => {
const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState("");
const [isGisEditSaving, setIsGisEditSaving] = useState(false);
const { ctpv, sgg } = useStore();
// 조사시설 관로번호는 전국에서 중복될 수 있어 시군구코드 앞 5자리와 함께 GIS 원천을 조회한다.
const exmnMapRegionCodes = useMemo(() => {
const ctpvOption = ctpv.find(
(option) => String(option.text ?? "").trim() === exmnMapEntryContext.ctpvNm,
);
const ctpvCd = String(ctpvOption?.val ?? "0").trim() || "0";
const sggOption = sgg.find((option) => {
const code = String(option.val ?? "").trim();
return (
String(option.text ?? "").trim() === exmnMapEntryContext.sggNm &&
(ctpvCd === "0" || code.startsWith(ctpvCd.slice(0, 2)))
);
});
return {
ctpvCd,
sggCd: String(sggOption?.val ?? "0").trim() || "0",
};
}, [
ctpv,
exmnMapEntryContext.ctpvNm,
exmnMapEntryContext.sggNm,
sgg,
]);
// GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다.
const gisEdit = useGisEdit(treeData);
// 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다.
@@ -2564,6 +2705,15 @@ const Map = () => {
const sinkholeSelectedSourceRef = useRef<VectorSource | null>(null);
const sinkholeFocusedPipeLayerRef = useRef<VectorLayer<any> | null>(null);
const sinkholeFocusedPipeSourceRef = useRef<VectorSource | null>(null);
const exmnPplLayerRef = useRef<VectorLayer<any> | null>(null);
const exmnPplSourceRef = useRef<VectorSource | null>(null);
const exmnMnhlLayerRef = useRef<VectorLayer<any> | null>(null);
const exmnMnhlSourceRef = useRef<VectorSource | null>(null);
const exmnPplRequestSeqRef = useRef(0);
const exmnMnhlRequestSeqRef = useRef(0);
const exmnInitialRegionAppliedRef = useRef(false);
// 조사관리에서 지도보기로 최초 진입했을 때만 조회된 대표 시설 한 건으로 지도를 맞춘다.
const exmnInitialFeatureFitAppliedRef = useRef(false);
// 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다.
const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false);
// 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다.
@@ -2874,6 +3024,40 @@ const Map = () => {
fetchData();
}, []);
useEffect(() => {
if (
!mapReady ||
exmnInitialRegionAppliedRef.current ||
(!exmnMapEntryContext.ctpvNm && !exmnMapEntryContext.sggNm)
) {
return;
}
const { ctpvCd, sggCd } = exmnMapRegionCodes;
// 시군구가 전달된 진입에서는 시도만 먼저 적용해 버리지 않고 두 코드가 모두 준비될 때까지 기다린다.
if (
(exmnMapEntryContext.ctpvNm && ctpvCd === "0") ||
(exmnMapEntryContext.sggNm && sggCd === "0")
) {
return;
}
exmnInitialRegionAppliedRef.current = true;
window.dispatchEvent(
new CustomEvent(EXMN_PROC_SEARCH_EVENT, {
detail: {
ctpvCd,
sggCd,
},
}),
);
}, [
exmnMapEntryContext.ctpvNm,
exmnMapEntryContext.sggNm,
exmnMapRegionCodes,
mapReady,
]);
useEffect(() => {
setAppliedSinkholeFilters((currentFilters) => {
if (isSinkholePopupFilterActive) {
@@ -2923,6 +3107,71 @@ const Map = () => {
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FIT_EXTENT) {
const extent = Array.isArray(data.payload?.extent)
? data.payload.extent.map(Number)
: [];
const map = gisMaps.current;
if (extent.length !== 4 || extent.some((value: number) => !Number.isFinite(value)) || !map?.getView) {
return;
}
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP) {
const extent = Array.isArray(data.payload?.extent)
? data.payload.extent.map(Number)
: [];
const sggCd = String(data.payload?.sggCd ?? "").trim();
const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim();
const map = gisMaps.current;
const select = GisApp.Module.select as any;
if (
extent.length !== 4
|| extent.some((value: number) => !Number.isFinite(value))
|| !/^\d{5}$/.test(sggCd)
|| !/^(030|050|080|100)$/.test(frequency)
|| !map?.getView
|| !select?.selectFocusedBoundaryFeature
) {
return;
}
await select.resetSelectedFeature?.();
const selectedFeatureIds = new Set<string>();
const selectLoadedGroupFeatures = (source: any) => {
const features = source?.getFeatures?.() ?? [];
features.forEach((feature: any) => {
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
const featureFrequency = String(feature.get?.("sbmrg_frq_expln") ?? "").trim();
if (featureSggCd !== sggCd || featureFrequency !== frequency) return;
const featureId = String(feature.getId?.() ?? feature.get?.("fid") ?? "");
if (featureId && selectedFeatureIds.has(featureId)) return;
if (featureId) selectedFeatureIds.add(featureId);
select.selectFocusedBoundaryFeature(feature, true);
});
};
map.getLayers().getArray().forEach((layer: any) => {
if (String(layer.get?.("lyrTyNm") ?? "").trim() !== FLOOD_DNGR_MAP_LAYER_TYPE) return;
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () => selectLoadedGroupFeatures(source));
});
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) {
const rawLayerTypeNames = data.payload?.refreshLayerTypeNames;
let layerTypeNames = ["tb_ms_swage_ppl"];
@@ -3053,10 +3302,13 @@ const Map = () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(
appendAgedPipeControlNode(
appendSinkholeControlNode(
normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)),
appendExmnResultControlNodes(
appendAgedPipeControlNode(
appendSinkholeControlNode(
normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)),
),
),
exmnMapEntryContext.jobType,
),
);
setLayerLoadError(null);
@@ -3955,6 +4207,138 @@ const Map = () => {
openAgedPipePopupWindow();
}, [openAgedPipePopupWindow]);
const removeExmnResultLayer = useCallback((jobType: ExmnMapJobType) => {
const layerId = jobType === "PPL"
? EXMN_RESULT_PPL_LAYER_ID
: EXMN_RESULT_MNHL_LAYER_ID;
const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef;
const sourceRef = jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef;
const requestSeqRef = jobType === "PPL"
? exmnPplRequestSeqRef
: exmnMnhlRequestSeqRef;
requestSeqRef.current += 1;
if (layerRef.current) {
(GisApp.Module.select as any)?.removeLayer?.(layerRef.current);
}
(GisApp.Module.core as any)?.deleteLayer?.(layerId);
layerRef.current = null;
sourceRef.current = null;
}, []);
const ensureExmnResultLayer = useCallback((jobType: ExmnMapJobType) => {
const map = gisMaps.current;
if (!map) return null;
const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef;
const sourceRef = jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef;
if (layerRef.current && sourceRef.current) return sourceRef.current;
const layerId = jobType === "PPL"
? EXMN_RESULT_PPL_LAYER_ID
: EXMN_RESULT_MNHL_LAYER_ID;
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: jobType === "PPL" ? 9988 : 9989,
style: jobType === "PPL" ? createExmnPipeStyle : createExmnManholeStyle,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", layerId);
layer.set("lyrNm", jobType === "PPL" ? "관로조사" : "맨홀조사");
layer.set("lyrTyNm", jobType === "PPL" ? "tb_ms_swage_ppl" : "tb_ms_swage_mhl");
layer.set("clickPsblYn", "Y");
layer.set("chcYn", "Y");
(GisApp.Module.core as any)?.deleteLayer?.(layerId);
map.addLayer(layer);
(GisApp.Module.select as any)?.addLayer?.(layer);
layerRef.current = layer;
sourceRef.current = source;
return source;
}, []);
const loadExmnResultLayer = useCallback(
async (jobType: ExmnMapJobType, filters: ExmnMapFilters) => {
const isChecked = jobType === "PPL" ? isExmnPplChecked : isExmnMnhlChecked;
if (!mapReady || !gisMaps.current || !isChecked) return;
const source = ensureExmnResultLayer(jobType);
if (!source) return;
const sggCodePrefix = exmnMapRegionCodes.sggCd
.replace(/[^0-9]/g, "")
.slice(0, 5);
// 공통 행정구역 코드가 준비되기 전에는 전국 중복 관로를 조회하지 않고 다음 상태 갱신을 기다린다.
if (sggCodePrefix.length !== 5) {
source.clear();
return;
}
const requestSeqRef = jobType === "PPL"
? exmnPplRequestSeqRef
: exmnMnhlRequestSeqRef;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
try {
const response = await getExmnMapLayer(jobType, filters, {
sggCd: sggCodePrefix,
ctpvNm: exmnMapEntryContext.ctpvNm,
sggNm: exmnMapEntryContext.sggNm,
});
if (requestSeqRef.current !== requestSeq) return;
const featureCollection = response?.data?.type === "FeatureCollection"
? response.data
: response;
const features = new GeoJSON().readFeatures(featureCollection, {
dataProjection: "EPSG:5179",
featureProjection: GisApp.TO_PROJ,
});
source.clear();
source.addFeatures(features);
// 표본 데이터가 적은 현재 단계에서는 최초 조사유형의 첫 시설을 기준으로 한 번만 확대한다.
if (
!exmnInitialFeatureFitAppliedRef.current &&
exmnMapEntryContext.jobType === jobType &&
features.length > 0
) {
const geometry = features[0]?.getGeometry?.();
const extent = geometry?.getExtent?.();
if (
extent &&
extent.length === 4 &&
extent.every((coordinate: number) => Number.isFinite(coordinate))
) {
gisMaps.current?.getView?.().fit?.(extent, {
duration: 300,
padding: [80, 420, 80, 80],
maxZoom: 18,
constrainResolution: true,
nearest: true,
});
exmnInitialFeatureFitAppliedRef.current = true;
}
}
} catch (error) {
if (requestSeqRef.current !== requestSeq) return;
source.clear();
console.error(`${jobType === "PPL" ? "관로조사" : "맨홀조사"} 레이어 조회 실패:`, error);
}
},
[
ensureExmnResultLayer,
exmnMapEntryContext.ctpvNm,
exmnMapEntryContext.jobType,
exmnMapEntryContext.sggNm,
exmnMapRegionCodes.ctpvCd,
exmnMapRegionCodes.sggCd,
isExmnMnhlChecked,
isExmnPplChecked,
mapReady,
],
);
const removeSinkholeLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_LAYER_ID);
@@ -4615,17 +4999,25 @@ const Map = () => {
}
const category = getRightPanelTabCategory(data);
const surveyJobTypeValue = getRightPanelFeatureProperty(data, ["jobType"]);
const surveyJobType =
surveyJobTypeValue === "PPL" || surveyJobTypeValue === "MNHL"
? surveyJobTypeValue
: undefined;
const featureId = getRightPanelFeatureIdentifier(data) || `${layerTypeName || "feature"}:${now}`;
// Direct map selections accumulate per pipe; only a pipe with a sinkhole parent replaces the linked slot.
const pipeParentTabId = category === "pipe" ? pipeParentTabIdRef.current : null;
return {
id:
category === "pipe" && pipeParentTabId
category === "exmnSurvey"
? `exmnSurvey:${getRightPanelFeatureProperty(data, ["exmnFacilityId"])}:${getRightPanelFeatureProperty(data, ["manholePosition"])}`
: category === "pipe" && pipeParentTabId
? RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID
: `feature:${layerTypeName || "unknown"}:${featureId}`,
kind: "feature",
category,
displayLabel: getRightPanelFeatureDisplayLabel(data, category),
surveyJobType,
parentTabId: pipeParentTabId,
featureData: data,
lastActiveAt: now,
@@ -5041,11 +5433,17 @@ const Map = () => {
}
const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node));
setIsSinkholeChecked(sinkholeChecked);
const exmnPplChecked = checkedNodesData.some((node) => isExmnResultPplNode(node));
const exmnMnhlChecked = checkedNodesData.some((node) => isExmnResultMnhlNode(node));
setIsExmnPplChecked(exmnPplChecked);
setIsExmnMnhlChecked(exmnMnhlChecked);
setActiveLayerSettingOrder((currentOrder) => {
const activeKeys = [
waterDepthChecked ? "waterDepthLegend" : null,
sinkholeChecked ? "sinkhole" : null,
agedPipeChecked ? "agedPipe" : null,
exmnPplChecked ? "exmnPpl" : null,
exmnMnhlChecked ? "exmnMnhl" : null,
].filter((key): key is string => Boolean(key));
return [
@@ -5060,7 +5458,13 @@ const Map = () => {
removeSinkholeLayer();
closeSinkholeDetail();
}
}, [clearFldngHstrySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]);
if (!exmnPplChecked) {
removeExmnResultLayer("PPL");
}
if (!exmnMnhlChecked) {
removeExmnResultLayer("MNHL");
}
}, [clearFldngHstrySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeExmnResultLayer, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]);
const waterDepthLegendItems = useMemo(
() => extractWaterDepthLegendItems(checkedNodesDataRef.current ?? []),
@@ -5081,6 +5485,24 @@ const Map = () => {
[sinkholeScope, sinkholeYearRange],
);
/** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
const openSbmrgDngrMapPopupWindow = useCallback(() => {
const popup = openWindowPopup(SBMRG_DNGR_MAP_POPUP_KEY as WindowPopupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
}
return popup;
}, []);
/** 도시침수지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
const openFloodDngrMapPopupWindow = useCallback(() => {
const popup = openWindowPopup(FLOOD_DNGR_MAP_POPUP_KEY as WindowPopupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
}
return popup;
}, []);
const handleOpenLayerPopup = useCallback(
(_layerNm: string, node?: any) => {
if (isAgedPipeLayerNode(node)) {
@@ -5091,6 +5513,14 @@ const Map = () => {
openSinkholePopupWindow();
return false;
}
if (isSbmrgDngrMapRootNode(node)) {
openSbmrgDngrMapPopupWindow();
return false;
}
if (isFloodDngrMapRootNode(node)) {
openFloodDngrMapPopupWindow();
return false;
}
if (isFldngHstryNode(node)) {
openFldngHstryPopupWindow();
return false;
@@ -5098,7 +5528,13 @@ const Map = () => {
return true;
},
[openAgedPipeList, openFldngHstryPopupWindow, openSinkholePopupWindow],
[
openAgedPipeList,
openFldngHstryPopupWindow,
openFloodDngrMapPopupWindow,
openSbmrgDngrMapPopupWindow,
openSinkholePopupWindow,
],
);
// 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다.
@@ -5109,6 +5545,36 @@ const Map = () => {
return index >= 0 ? index : fallback;
};
if (isExmnPplChecked) {
nextItems.push({
key: "exmnPpl",
label: "관로조사",
order: resolveSettingOrder("exmnPpl", 9995),
content: (
<ExmnLayerSettingPanel
jobType="PPL"
filters={exmnPplFilters}
onFiltersChange={setExmnPplFilters}
/>
),
});
}
if (isExmnMnhlChecked) {
nextItems.push({
key: "exmnMnhl",
label: "맨홀조사",
order: resolveSettingOrder("exmnMnhl", 9996),
content: (
<ExmnLayerSettingPanel
jobType="MNHL"
filters={exmnMnhlFilters}
onFiltersChange={setExmnMnhlFilters}
/>
),
});
}
if (isSinkholeChecked) {
nextItems.push({
key: "sinkhole",
@@ -5185,8 +5651,12 @@ const Map = () => {
applySinkholeRadius,
applySinkholeYearRange,
draftAgedPipeFilters,
exmnMnhlFilters,
exmnPplFilters,
handleApplyAgedPipeFilters,
isAgedPipeChecked,
isExmnMnhlChecked,
isExmnPplChecked,
isSinkholeChecked,
isWaterDepthLayerActive,
lastSinkholeSyncSummary,
@@ -5196,12 +5666,27 @@ const Map = () => {
waterDepthLegendItems,
]);
// 설정값 변경으로 설정 컴포넌트가 다시 만들어져도 사용자가 보던 내부 스크롤 위치를 유지한다.
const activeLayerSettingScrollTopRef = useRef(0);
const handleActiveLayerSettingScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
activeLayerSettingScrollTopRef.current = event.currentTarget.scrollTop;
},
[],
);
const restoreActiveLayerSettingScroll = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
element.scrollTop = Math.min(activeLayerSettingScrollTopRef.current, maxScrollTop);
}, []);
// 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다.
const ActiveLayerSettingView = useCallback(() => {
if (activeLayerSettingItems.length === 0) return null;
return (
<div className="mt-3 overflow-hidden rounded-sm border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="mt-3 flex h-[34vh] min-h-[24rem] max-h-[32rem] flex-col overflow-hidden rounded-sm border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="border-b border-border bg-secondary px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[1.2rem] font-semibold text-label">선택 레이어 설정</span>
@@ -5218,7 +5703,11 @@ const Map = () => {
))}
</div>
</div>
<div className="max-h-[28vh] overflow-y-auto px-1 py-2 [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]">
<div
ref={restoreActiveLayerSettingScroll}
onScroll={handleActiveLayerSettingScroll}
className="min-h-0 flex-1 overflow-y-scroll px-1 py-2 [scrollbar-gutter:stable] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]"
>
<div className="flex flex-col gap-2">
{activeLayerSettingItems.map((item) => (
<Fragment key={item.key}>{item.content}</Fragment>
@@ -5227,7 +5716,11 @@ const Map = () => {
</div>
</div>
);
}, [activeLayerSettingItems]);
}, [
activeLayerSettingItems,
handleActiveLayerSettingScroll,
restoreActiveLayerSettingScroll,
]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
@@ -5973,6 +6466,7 @@ const Map = () => {
<FeatureClickEventPopup
{...props}
featureData={panelFeatureData}
surveyMode={activeRightPanelTab?.category === "exmnSurvey"}
onClose={() => {
setGisEditDetailChangeId(null);
closeActiveTab();
@@ -6971,6 +7465,34 @@ const Map = () => {
}
}, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]);
useEffect(() => {
if (!mapReady || !isExmnPplChecked) {
removeExmnResultLayer("PPL");
return;
}
void loadExmnResultLayer("PPL", exmnPplFilters);
}, [
exmnPplFilters,
isExmnPplChecked,
loadExmnResultLayer,
mapReady,
removeExmnResultLayer,
]);
useEffect(() => {
if (!mapReady || !isExmnMnhlChecked) {
removeExmnResultLayer("MNHL");
return;
}
void loadExmnResultLayer("MNHL", exmnMnhlFilters);
}, [
exmnMnhlFilters,
isExmnMnhlChecked,
loadExmnResultLayer,
mapReady,
removeExmnResultLayer,
]);
useEffect(() => {
if (!mapReady || !isSinkholeChecked) {
removeSinkholeLayer();
@@ -7267,7 +7789,11 @@ const Map = () => {
treeTogglePosition="center"
zoomGuide={zoomGuide}
layerIconVisible={(node: any) =>
isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFldngHstryNode(node)
isAgedPipeLayerNode(node)
|| isSinkholeRootNode(node)
|| isSbmrgDngrMapRootNode(node)
|| isFloodDngrMapRootNode(node)
|| isFldngHstryNode(node)
}
defaultZoom={7.5}
@@ -0,0 +1,6 @@
import { ObsltPplListPage } from "@/features/obsltPpl/ui/ObsltPplListPage";
/** 노후하수관로 정밀조사·정비사업 목록 경로다. */
export default function Page() {
return <ObsltPplListPage />;
}
@@ -0,0 +1,7 @@
import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage";
/** 노후하수관로 사업 수정·삭제 경로다. */
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <ObsltPplFormPage id={id} />;
}
@@ -0,0 +1,6 @@
import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage";
/** 노후하수관로 사업 신규 등록 경로다. */
export default function Page() {
return <ObsltPplFormPage />;
}
@@ -39,6 +39,8 @@ interface CtpvSggPrcsFcltSelectBoxProps {
setIsLoading?: (isLoading: boolean) => void;
/** 좁은 조회·입력 행에서 시도·시군구 선택 폭과 간격을 줄여 표시한다. */
compact?: boolean;
/** 화면별 배경에 맞춰 시도·시군구 선택값의 글자색 등을 직접 지정한다. */
selectValueClassName?: string;
}
const defaultOption: ApiSelectOption = { val: '0', text: '전체' };
@@ -62,6 +64,7 @@ export function CtpvSggPrcsFcltSelectBox({
defaultSwtrmtFcltNm,
setIsLoading,
compact = false,
selectValueClassName = "",
}: CtpvSggPrcsFcltSelectBoxProps) {
const [prcsFcltOptions, setPrcsFcltOptions] = useState<ApiSelectOption[]>([]);
@@ -138,7 +141,7 @@ export function CtpvSggPrcsFcltSelectBox({
<Label className={`${compact ? "mr-1.5 text-base" : "mr-2 text-xl"} whitespace-nowrap font-medium text-gray-600`}>시도</Label>
<SelectBox
className={compact ? "mr-4 w-[180px]" : "mr-8 w-48"}
selectClass={`${compact ? "w-[180px]" : ""} rounded-lg bg-white`}
selectClass={`${compact ? "w-[180px]" : ""} rounded-lg bg-white ${selectValueClassName}`}
label=""
selectArray={[defaultOption, ...ctpv]}
value={sido}
@@ -152,7 +155,7 @@ export function CtpvSggPrcsFcltSelectBox({
<Label className={`${compact ? "mr-1.5 text-base" : "mr-2 text-xl"} whitespace-nowrap font-medium text-gray-600`}>시군구</Label>
<SelectBox
className={compact ? "w-36" : "w-48"}
selectClass={`${compact ? "w-36" : "w-48"} truncate rounded-lg bg-white`}
selectClass={`${compact ? "w-36" : "w-48"} truncate rounded-lg bg-white ${selectValueClassName}`}
label=""
selectArray={[defaultOption, ...sgg.filter(opt => opt.val.startsWith(sidoPrefix))]}
value={sigungu}
@@ -230,3 +230,48 @@
| 다운로드 | 관로·맨홀 다운로드 | 종합집계 1개와 처리분구 상세 N개 시트 생성 |
| 과거 URL | `/oper/exmn/register?sample=Y` 진입 | 파라미터를 무시하고 실제 API 데이터 표시 |
| 지도보기 | 버튼 클릭 | 보류 안내 유지 |
## 17. 지도보기 이동·조사결과 레이어 검증 계획
| 검증 항목 | 방법 | 기대 결과 |
|---|---|---|
| 지도 진입 | 조사 상세현황에서 관로 또는 맨홀 선택 후 지도보기 | `/map`으로 이동하고 선택 조사유형이 최초 체크됨 |
| 행정구역 초기화 | 경기도·하남시 사업에서 지도 진입 | 지도 검색조건에 시도·시군구가 표시되고 해당 지역으로 이동 |
| 레이어 트리 | 지도 왼쪽 레이어 확인 | `조사결과 > 관로조사 / 맨홀조사` 표시 |
| 자동 필터 | 사업차수·구조·운영 등급 변경 | 적용 버튼 없이 해당 레이어가 다시 조회됨 |
| AND 조건 | 구조·운영 등급을 함께 선택 | 두 조건을 모두 만족하는 시설만 표시 |
| 관로 등급 | 소수점 평균등급 데이터 조회 | 정수부 등급 기준으로 필터링 |
| 동시 레이어 | 관로조사와 맨홀조사를 모두 체크 | 두 레이어가 함께 표시되고 설정값은 독립 유지 |
| 체크 해제 | 각 조사 레이어 체크 해제 | 해당 벡터 레이어가 지도에서 제거 |
| 빠른 조건 변경 | 필터를 연속 변경 | 이전 응답이 마지막 조건 결과를 덮어쓰지 않음 |
| 회귀 | 기존 일반·분석 레이어 선택 | 기존 레이어 설정·선택 동작 유지 |
## 18. 지도 우측 조사관리 탭 검증 계획
| 검증 항목 | 방법 | 기대 결과 |
|---|---|---|
| 전용 탭 | PPL·MNHL 조사 레이어 피처 클릭 | 노란 `관로조사(번호)` 또는 보라 `맨홀조사(번호)` 탭 생성 |
| 기존 정보 | 조사관리 탭 내용 확인 | 기본정보와 입력정보가 일반 시설 상세와 동일하게 표시 |
| 조사 결과 | 입력정보 하단 확인 | 사업·처리분구·보고서번호·구조/운영 등급 표시 |
| 관로 보고서 | PPL의 조사보고서 보기 클릭 | 해당 관로 페이지 범위 PDF가 공통 WindowPopup에 표시 |
| 미등록 보고서 | 보고서 미등록 PPL 클릭 | 오류 안내 후 빈 창 닫힘 |
| 맨홀 정책 | MNHL 조사관리 탭 확인 | PDF 버튼과 별도 보류 안내 모두 미표시 |
| 탭 독립성 | 일반 관로와 조사 관로를 차례로 클릭 | 두 탭이 서로 덮어쓰지 않음 |
## 19. 2026-07-31 최종 통합 회귀 계획
| 검증 영역 | 확인 방법 | 기대 결과 |
|---|---|---|
| 조사관리 기본 기능 | 처리분구 CRUD부터 결과등록·상세·다운로드까지 대표 정상 시나리오 재수행 | 이전 단계에서 확인한 저장·조회·집계 결과 유지 |
| 지도 행정구역 | 경기도 하남시 사업의 지도보기 진입 | 시도·시군구가 모두 선택되고 대상 지역 및 대표 시설로 이동 |
| 레이어 표시 | 관로·맨홀을 각각 및 동시에 ON/OFF | 관로는 노란 선, 맨홀은 보라 맨홀 아이콘으로 표시 |
| 레이어 필터 | 사업차수와 구조·운영 등급을 연속 변경 | 마지막 조건만 반영되고 설정 스크롤 위치 유지 |
| 맨홀 지역 범위 | 타 시군구에 같은 맨홀번호가 존재하는 데이터로 조회 | 요청 시군구 앞 5자리의 맨홀만 반환 |
| 지도 탭 | 관로·맨홀 피처를 차례로 선택 | `관로조사(번호)`·`맨홀조사(번호)` 탭과 조사 결과 표시 |
| 일반 시설 회귀 | 일반 관로·맨홀 피처 선택 | 기존 기본정보·입력정보·유지관리 영역 유지 |
| 관로 보고서 | 페이지 범위 저장 전·후 각각 조회 | 최초 분석·저장 후 부분 PDF 표시, 재조회는 저장 범위 사용 |
| 관로 보고서 오류 | 첨부 없음·번호 판독 실패·선택 관로 미포함 | WindowPopup 본문에 업무 오류 표시 |
| 맨홀 보고서 | 맨홀 조사 탭 확인 | 협의 전까지 보고서 버튼과 보류 안내 모두 미표시 |
- 레이어 API 단위 테스트는 실제 서비스가 호출하는 Repository 메서드와 동일한 메서드를 모킹해야 한다.
- 전체 빌드가 장시간 걸리는 환경에서는 변경 파일 정적 검사 후 백엔드 대상 테스트와 로그인 브라우저 회귀를 분리한다.
@@ -496,3 +496,87 @@
- 백엔드 재기동 후 관로·맨홀 상세표에서 `우수관`, `흉관(HP)` 명칭을 확인한다.
- 배수방식·관종 헤더 정렬이 표시 명칭 순으로 동작하는지 확인한다.
- 클라이언트 상세 Excel과 서버 다중 시트 Excel에서 코드 대신 명칭이 출력되는지 확인한다.
## 31. 2026-07-31 지도보기 이동·조사결과 레이어 정적 검증
| 검증 항목 | 결과 | 비고 |
|---|---|---|
| 지도 이동 query | 통과 | 사업·행정구역·조사유형·처리분구 전달 |
| 초기 행정구역 연계 | 통과 | 기존 지도 검색 이벤트 재사용 |
| 조사결과 트리 구성 | 통과 | 루트와 PPL·MNHL 가상 노드 구성 |
| 설정 조건 | 통과 | 사업차수·구조·운영 등급 3개만 제공 |
| 자동 조회 | 통과 | 설정 변경 시 레이어 재조회 |
| 레이어 분리 | 통과 | PPL 선·MNHL 점 소스와 필터 상태 독립 |
| 응답 순서 보호 | 통과 | 조사유형별 요청 순번 적용 |
| 변경 공백 검사 | 통과 | `git diff --check` |
| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단, 직접 eslint 모듈 접근 `EPERM` |
| 브라우저 수동 검증 | 대기 | 서버 재기동 후 실제 지도에서 확인 필요 |
### 남은 수동 확인
- 조사관리에서 진입한 시도·시군구가 지도 검색조건과 중심 위치에 동일하게 반영되는지 확인한다.
- 관로조사·맨홀조사 레이어를 각각 또는 동시에 켜고 3개 조건의 자동 필터 결과를 대조한다.
- 조사시설을 클릭했을 때 기존 선택 모듈이 시설을 인식하는지 확인한다.
- 전용 오른쪽 조사관리 탭은 다음 구현 단계에서 별도로 검증한다.
## 32. 2026-07-31 지도 우측 조사관리 탭 정적 검증
| 검증 항목 | 결과 | 비고 |
|---|---|---|
| 조사관리 탭 분기 | 통과 | `surveyOverlayType=EXMN_RESULT`를 일반 시설보다 우선 판별 |
| 탭 식별자 | 통과 | 조사시설물 ID와 맨홀 위치로 구성 |
| 조사 결과 주입 | 통과 | 관로·맨홀 입력정보 컴포넌트에 선택적 ReactNode 전달 |
| 관로 부분 PDF 호출 | 통과 | 사업 ID와 관로번호를 URL 인코딩하여 Blob 조회 |
| 맨홀 PDF 보류 | 통과 | 버튼과 별도 보류 안내 모두 미표시 |
| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` |
| 브라우저 수동 검증 | 대기 | 개발 서버에서 실제 지도 선택과 팝업 허용 상태 확인 필요 |
- 사용자 요청에 따라 전체 프론트 빌드와 장시간 Gradle 작업은 수행하지 않았다.
## 33. 2026-07-31 조사관리 탭 유지관리 영역·부분 PDF 보정
| 검증 항목 | 결과 | 비고 |
|---|---|---|
| 조사관리 전용 표시 | 정적 통과 | 조사결과 아래 일반 유지관리 3개 영역 숨김 |
| 일반 관로 탭 회귀 | 정적 통과 | 조사관리 탭이 아닐 때 기존 유지관리 영역 유지 |
| 부분 관로 PDF | 정적 통과 | PDF에 포함된 활성 관로 쪽 범위만 저장 |
| PDF의 미등록 관로 | 정적 통과 | 활성 조사시설에 없는 번호가 있으면 저장 거부 |
| 선택 관로 PDF 누락 | 정적 통과 | 선택 관로 보고서 없음 메시지 반환 |
| 브라우저·단위 테스트 | 대기 | 백엔드 재기동 후 실제 첨부 PDF로 확인 필요 |
## 34. 2026-07-31 최종 정적 회귀점검
| 검증 항목 | 결과 | 비고 |
|---|---|---|
| 처리분구·사업·결과등록 | 구현 확인 | 실제 API 호출과 저장 후 재조회 흐름 유지 |
| 종합·전체·본단위·결함별 조회 | 구현 확인 | PPL/MNHL 및 `ALL/STRUCTURAL/OPERATIONAL` 계약 유지 |
| 다중 시트 다운로드 | 구현 확인 | 종합 1 + 처리분구 상세 N, 본단위구간 제외 |
| 지도 이동·행정구역 | 구현 확인 | 시도·시군구 query와 공통 검색 이벤트 연결 |
| 지도 조사 레이어 | 구현 확인 | PPL 선·MNHL 점, 사업차수·구조·운영 등급 필터 |
| 조사유형 시각 구분 | 구현 확인 | 관로 노란색, 맨홀 보라색, 탭 명칭에 조사유형 표시 |
| 관로 부분 PDF | 구현 확인 | `exmnPipeReport` WindowPopup과 Blob URL 정리 |
| 일반 시설 회귀 | 정적 확인 | 조사 탭에서만 유지관리 영역을 숨김 |
| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` |
| 전체 빌드·대상 테스트 | 미실행 | 장시간 실행 이력과 사용자 검증 방침에 따라 생략 |
### 정적 보완 결과
1. `ExmnSurveyResultSection.tsx`의 `!p-0r`를 `!p-0`으로 수정했다.
2. 맨홀 공간조회에도 관로와 동일한 시군구코드 앞 5자리 제한을 적용했다.
3. `ExmnMapLayerServiceTest`의 맨홀 Repository 모킹 대상을 실제 서비스 호출 메서드와 지역 인자에 맞췄다.
### 최종 수동 확인
- 로그인된 지도에서 관로·맨홀 동시 표시, 필터 연속 변경, 설정 스크롤 유지와 우측 탭 시각 구분을 확인한다.
- 실제 관로 PDF를 페이지 범위 미저장·저장 상태에서 각각 열어 WindowPopup 표시와 재사용 여부를 확인한다.
- 동일 맨홀번호가 여러 시군구에 있는 자료로 지역 제한 적용 후 반환 지점을 대조한다.
## 35. 2026-07-31 관로 보고서 미등록 선분기 점검
| 검증 항목 | 결과 | 비고 |
|---|---|---|
| 지도 응답 첨부 여부 | 정적 통과 | `reportAvailable` 속성 전달 |
| 보고서 미등록 | 정적 통과 | WindowPopup 없이 alert 표시 |
| 보고서 등록 | 정적 통과 | 기존 `exmnPipeReport` WindowPopup 호출 |
| 부분 PDF 분석 실패 | 기존 동작 유지 | 팝업 내부 백엔드 오류 표시 |
| 브라우저 수동 검증 | 대기 | 보고서 등록·미등록 사업을 각각 선택해 확인 |
@@ -0,0 +1,20 @@
# SN-노후하수관로정비사업 FE 통합검증계획
## 자동 검증
- `pnpm --filter sn run build`
- 신규 세 경로의 Next 산출물 생성 확인
- 변경 파일 `git diff --check`
## 브라우저 검증
1. 목록 전체 23개 열과 가로 스크롤 확인
2. 검색·페이지 이동 확인
3. 새 `_upload.xlsx`의 헤더 오류 없이 FE에서 517건 파싱되고 네트워크 요청이 JSON 배열인지 확인
4. 엑셀다운로드 후 두 시트 및 재업로드 확인
5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인
6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인
## 선행조건
로그인 세션, 메뉴 권한, sewerage-service, 생성된 `tb_sn_obslt_swage_ppl_biz`가 필요하다.
@@ -0,0 +1,17 @@
# SN-노후하수관로정비사업 FE 통합검증결과
## 2026-08-03
- 신규 TS/TSX 파일 대상 ESLint를 실행했고 오류·경고 없이 통과했다.
- 선행 빌드에서 목록·등록·수정 세 경로의 `page.js` 산출을 확인했다.
- 독립 `tsc`는 저장소에 설치된 TypeScript가 `ignoreDeprecations: 6.0`을 지원하지 않아 환경 오류로 종료했다.
- 최종 전체 빌드는 신규 화면이 아닌 공통 `file-uploader`, `slider`, `switch`, `table`, `tabs` CSS 처리 중 Windows `EMFILE: too many open files`로 실패했다.
- 로그인·실 API 브라우저 검증은 실행환경 미기동으로 보류한다.
- 지역 검색 유지보수 후 `pnpm exec eslint apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx`를 실행해 오류·경고 없이 통과했다.
## 2026-08-04
- 새 업로드 양식의 두 시트 B6:V9를 값 검사와 렌더링으로 확인했으며 수정 헤더가 정상 표시됨을 확인했다.
- 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다.
- 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다.
- SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다.
@@ -0,0 +1,67 @@
# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 계획
## 1. 문서 목적
- 도시침수지도 목록보기와 WMS·SLD 일반 표출의 정합성·성능·회귀 여부를 검증한다.
## 2. 검증 범위
- `sm_bs_sn.tb_sn_lyr_stng` 부모 3건과 자식 15건
- 30·50·80년 레이어 트리 ON/OFF
- TileWMS 요청 파라미터와 SLD 규칙
- 기존 침수심도 범례 색상·투명도·선 두께
- 기존 WFS 레이어 회귀
- 목록 팝업과 행 선택 지도 이동 회귀
## 3. 검증 기준
| 영역 | 통과 기준 |
|---|---|
| 부모 설정 | 58·59·60이 `wms/Y`, 빈도 필터가 각각 `080/030/050` |
| 자식 설정 | 부모별 5건, 전체 `wms/N`, `N330~N334` 각 1건 |
| 화면 | 30·50·80년 각각 ON/OFF 즉시 반영 |
| WMS | `GetMap`, PNG, 투명 배경, EPSG:5179 |
| 빈도 필터 | 선택 빈도에 맞는 `CQL_FILTER` |
| SLD | 접두사 없는 NamedLayer와 침수심도 Rule 5개 |
| 스타일 | DB의 면색·선색·opacity·선 두께와 일치 |
| 회귀 | 다른 WFS 레이어와 목록 팝업 기능 유지 |
| 성능 | 전국 초기 진입 시 대용량 GeoJSON 파싱·벡터 렌더링 없음 |
## 4. 검증 절차
### 4.1 정적·빌드 검증
1. SLD 생성 헬퍼에 자식 5건을 입력한다.
2. Rule 5개, 색상·opacity, NamedLayer, 누락·중복 차단을 확인한다.
3. 변경 파일 ESLint를 실행한다.
4. `@common/business_components`를 빌드한다.
### 4.2 DB 검증
1. 부모 58·59·60의 필터·서비스·실제 레이어 여부를 조회한다.
2. 부모별 자식 수와 `wms/N` 개수를 집계한다.
3. `N330~N334`가 부모별 1건인지 확인한다.
### 4.3 브라우저 검증
1. 30·50·80년을 각각 단독 ON/OFF한다.
2. Network에서 `GetMap`과 `tb_ms_flood_dngr_map`을 필터링한다.
3. `CQL_FILTER`가 각각 `030`, `050`, `080`인지 확인한다.
4. `SLD_BODY`에 `N330~N334`와 기존 색상값이 있는지 확인한다.
5. 응답 상태 200과 `image/png`을 확인한다.
6. 도시침수지도 관련 WFS `GetFeature`가 발생하지 않는지 확인한다.
7. 목록 팝업 검색·행 선택 지도 이동을 회귀 확인한다.
## 5. 관측 포인트
- 논리 WMS 레이어 수와 타일 HTTP 요청 수를 구분한다.
- GeoServer가 SLD 레이어명을 무시하고 기본 검정 스타일을 반환하지 않는지 결과 이미지를 확인한다.
- 자식 체크 상태는 범례 유지용이며 지도 레이어 객체가 생성되면 실패다.
## 6. 다음 검증 순서
- 개발환경 검증 완료 후 운영 프론트 배포 → 운영 DB DML → 운영 Network·체감 성능 순으로 확인한다.
## 7. 흡수한 기존 문서
- 별도 SN 검증 계획 문서는 없었다.
@@ -0,0 +1,69 @@
# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 결과
## 1. 문서 목적
- 2026-08-05 WMS·SLD 전환 구현의 실제 수행 검증과 현재 완료 상태를 기록한다.
## 2. 수행한 검증
### 2.1 코드 검증
| 항목 | 결과 | 비고 |
|---|---|---|
| SLD Rule 생성 | 통과 | `N330~N334` 5개 |
| RGBA 변환 | 통과 | 색상과 fill/stroke opacity 분리 |
| 설정 누락·중복 차단 | 통과 | 정확히 5건이 아니면 생성 중단 |
| ESLint | 통과 | 오류 0건, 기존 import 정렬 경고만 존재 |
| `@common/business_components` 빌드 | 통과 | 의존 작업 포함 4/4 성공 |
| `git diff --check` | 통과 | whitespace 오류 없음 |
### 2.2 DB 검증
| 부모 | 빈도 필터 | 부모 상태 | 자식 수 | 자식 WMS 수 | 자식 메타데이터 수 | 결과 |
|---:|---|---|---:|---:|---:|---|
| 58 | `080` | `wms/Y` | 5 | 5 | 5 | 정상 |
| 59 | `030` | `wms/Y` | 5 | 5 | 5 | 정상 |
| 60 | `050` | `wms/Y` | 5 | 5 | 5 | 정상 |
- 각 부모의 자식은 `N330~N334`가 1건씩 존재했다.
- 자식의 `exprs_yn=N`, `actl_lyr_yn=N`, 기존 필터·색상·선 두께가 유지됐다.
- 100년 빈도는 전환 대상에 포함되지 않았다.
### 2.3 화면·네트워크 검증
- 사용자가 30·50·80년 레이어 ON/OFF 정상 동작을 확인했다.
- 30년 실제 요청에서 다음 값을 확인했다.
- `SERVICE=WMS`
- `VERSION=1.1.1`
- `REQUEST=GetMap`
- `LAYERS=hasudo:tb_ms_flood_dngr_map`
- `SRS=EPSG:5179`
- `FORMAT=image/png`
- `TRANSPARENT=true`
- `CQL_FILTER=sbmrg_frq_expln='030'`
- SLD `NamedLayer/Name=tb_ms_flood_dngr_map`
- SLD Rule `N330~N334` 5개
- DB와 동일한 채움색·경계선색·opacity·선 두께
## 3. 현재 검증 상태
- 개발 DB 전환, 화면 ON/OFF, 30년 WMS·SLD 요청, 공통 패키지 빌드까지 정상이다.
- 일반 표출의 최종 구조는 부모 3개 TileWMS와 자식 15개 SLD·범례 메타데이터로 확정한다.
- 기능 상태는 `완료`로 기록하고 운영 반영 확인은 배포 절차 점검으로 관리한다.
## 4. 미수행·운영 확인 항목
- 50년·80년의 개별 Network 파라미터 캡처는 남아 있다.
- 응답 헤더의 `Content-Type=image/png` 별도 캡처는 남아 있다.
- 전체 SN Next 빌드는 실행 중인 개발 서버 보호를 위해 수행하지 않았다.
- 운영 환경의 WFS 미발생과 최초 화면 체감 성능은 운영 적용 직후 확인한다.
## 5. 롤백 기준
- DB는 부모를 `wfs/N` 및 빈 필터로, 자식을 `wfs/Y`로 복원한다.
- 프론트는 `gis.wms-sld.js`와 WMS 연동 변경을 되돌리면 기존 WFS 구조로 복귀한다.
- 자식 필터·범례 색상값은 롤백 시에도 보존한다.
## 6. 흡수한 기존 문서
- 별도 SN 검증 결과 문서는 없었다.
@@ -280,3 +280,59 @@
- `ExmnSurveyDetailPopupPage`의 데이터행과 정렬값은 `drainageTypeName || drainageType`, `pipeTypeName || pipeType`으로 구성한다.
- 미등록 코드도 빈칸으로 숨기지 않고 코드 원문을 표시한다.
- 완료 게이트는 관로·맨홀 전체·구조·운영 상세 화면에서 코드가 한글 명칭으로 표시되고, 명칭 정렬과 서버 XLSX가 동일한 값을 사용하는 것이다.
## 22. 2026-07-30 관로 상세보기 식별자·등급 열 보정
- 상세보기 조회 조건의 `관리번호`, `관리번호 입력` 문구를 `관로번호`, `관로번호 입력`으로 통일한다.
- 관로 전체 상세표의 고정 열에 `구조적 내부결함`, `운영적 내부결함` 등급을 다시 포함한다.
- 화면은 상세 API의 `structuralGrade`, `operationalGrade`를 사용하며 프론트에서 등급 산정 공식을 중복 구현하지 않는다.
- 관로·맨홀의 데이터행, 소계·합계와 클라이언트 상세 Excel에 두 등급 열을 동일하게 구성한다.
## 23. 2026-07-31 지도보기 진입·조사결과 레이어 연계
| 작업 단위 | 상태 | 범위 |
|---|---|---|
| 조사 상세현황 지도보기 이동 | 완료 | 선택 사업·조사유형·행정구역·처리분구 정보를 `/map` query로 전달 |
| 초기 지도 위치 동기화 | 완료 | 전달받은 시도·시군구 명칭을 기존 검색 조건 코드로 변환하고 기존 지도 검색 이벤트로 이동 |
| 조사결과 레이어 트리 | 완료 | `조사결과 > 관로조사 / 맨홀조사` 가상 제어 노드 추가 |
| 조사결과 설정 UI | 완료 | 사업차수·구조적 상태등급·운영적 상태등급 3개 조건을 적용 버튼 없이 자동 반영 |
| 조사결과 지도 레이어 | 완료 | PPL 선·MNHL 점 GeoJSON을 별도 벡터 레이어로 조회하고 체크 해제 시 제거 |
| 오른쪽 조사관리 탭 | 대기 | 선택 관로·맨홀의 조사결과 및 조사보고서 연결은 다음 단계 |
- 구조적 상태등급과 운영적 상태등급을 함께 입력하면 백엔드 API의 `AND` 조건을 그대로 사용한다.
- 관로 평균등급의 소수점 절사는 백엔드 계약을 사용하며 프론트에서 중복 계산하지 않는다.
- 관로조사와 맨홀조사는 동시에 켤 수 있고 각 레이어의 필터 상태를 독립적으로 유지한다.
- 빠른 조건 변경 시 이전 응답이 최신 레이어를 덮어쓰지 않도록 요청 순서를 보호한다.
## 24. 2026-07-31 지도 우측 조사관리 탭
| 작업 단위 | 상태 | 범위 |
|---|---|---|
| 조사관리 전용 탭 | 완료 | 조사결과 레이어 선택 시 `관로조사(번호)`·`맨홀조사(번호)` 탭 생성 |
| 기존 시설정보 재사용 | 완료 | 관로·맨홀 기본정보와 입력정보를 기존 상세 컴포넌트로 표시 |
| 조사 결과 표시 | 완료 | 사업명·사업차수·처리분구·조사유형·보고서번호·구조/운영 등급 표시 |
| 관로 조사보고서 열람 | 완료 | 선택 관로의 저장 페이지 범위를 PDF로 조회해 공통 WindowPopup에 표시 |
| 맨홀 조사보고서 | 보류 | 보고서 판별 기준 협의 후 연결 |
- 조사 레이어 GeoJSON에는 기존 시설 상세 API 호출에 필요한 `fclt_mng_no`, `mng_inst_cd`가 포함되어야 한다.
- 조사관리 탭 식별자는 조사시설물 ID와 맨홀 위치 구분을 사용해 일반 관로 탭과 충돌하지 않도록 한다.
## 25. 2026-07-31 프론트 최종 구현 상태
| 기능 단위 | 상태 | 최종 범위 |
|---|---|---|
| 처리분구 관리 | 완료 | 최초 조회, 그리드 직접 추가·수정, 저장·삭제 후 목록 동기화 |
| 조사사업 관리 | 완료 | 조회·정렬·행 선택, 등록·수정 WindowPopup, 조사·보고서 양식 다운로드 |
| 결과등록 | 완료 | 조사결과 Excel·관로/맨홀 PDF 검토 상태, 5종 파일 선택, 최종 저장 |
| 조사 상세현황 | 완료 | 관로·맨홀 종합집계, 처리분구 행 선택, 전체·구조·운영 상세 |
| 관로 본단위구간 | 완료 | 처리분구 전체 및 관로번호 검색, 단위구간별 행 표시 |
| 통합 다운로드 | 완료 | 종합집계 1개 + 처리분구 상세 N개 시트, 본단위구간 제외 |
| 지도 이동·레이어 | 구현 완료·통합 검증 필요 | 행정구역 이동, 관로·맨홀 레이어, 사업차수·등급 필터 |
| 지도 조사 탭 | 구현 완료·통합 검증 필요 | `관로조사(번호)`·`맨홀조사(번호)`, 기존 기본/입력정보와 조사결과 |
| 관로 부분 PDF | 구현 완료·통합 검증 필요 | 첨부 존재 시 공통 WindowPopup 표시, 미등록 시 팝업 없이 alert 안내 |
| 맨홀 부분 PDF | 보류 | 보고서 내 맨홀 판별·쪽 구간 기준 협의 필요 |
- 지도 조사유형 표시는 관로 노란색, 맨홀 보라색을 사용한다.
- 지도 피처 선택 강조는 조사유형 색과 분리하고 기존 공통 지도 선택 효과를 유지한다.
- 관로 PDF는 새 브라우저 탭이 아니라 `exmnPipeReport` WindowPopup 안에서 표시한다.
- 최종 완료 판정 전에는 지도 관로·맨홀 동시 ON/OFF, 필터 변경, 시설 선택, 관로 PDF 성공·실패 흐름의 로그인 브라우저 회귀가 필요하다.
@@ -0,0 +1,29 @@
# SN-노후하수관로정비사업 FE 작업카드
## 목표
`/oper/obsltPplBiz`에 원본 Excel 전체 항목 목록과 검색·등록·업로드·엑셀다운로드 기능을 제공하고 등록·수정 화면을 분리한다.
## 구현 계약
- 목록은 23개 컬럼을 모두 노출하고 `overflow-x-auto`로 가로 스크롤한다.
- 목록 행 클릭은 `/oper/obsltPplBiz/register/{id}`로 이동한다.
- 목록에는 삭제 버튼을 두지 않는다.
- 등록 화면 하단은 `목록`, `저장`; 수정 화면은 `목록`, `저장`, `삭제` 순서다.
- `apps/wr /matMng/coMat/ruseCaseMng`의 브라우저 Excel 파싱·JSON 저장 흐름을 참고하되 SN 전용 두 시트·병합 헤더 계약으로 구현한다.
- `apps/cs/banner/register`의 표형 폼과 별도 등록/수정 경로를 시각 참고한다.
- 목록 검색의 시도·시군구는 SN 앱 공통 `useStore`의 지역 목록을 사용하는 연계 `SelectBox`로 제공하고, 시도 변경 시 시군구를 초기화한다.
## API 및 관측 포인트
- 목록 로딩·오류·업로드 결과 건수는 화면에서 문구로 관측 가능해야 한다.
- 업로드 input은 `.xlsx`만 받는다.
- FE는 `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 두 시트와 B6:V7 핵심 헤더를 검증하고 B8:V 마지막 행을 JSON 저장 요청으로 변환한다.
- 다운로드 버튼명은 `엑셀다운로드`로 고정한다.
- 브라우저 검증 URL: `/oper/obsltPplBiz`, `/oper/obsltPplBiz/register`, `/oper/obsltPplBiz/register/{id}`
## 제외
- 기존 `/oper/oldPp` 변경
- `apps/cs` 또는 공통 컴포넌트 변경
- 메뉴/권한 운영 데이터 변경
@@ -687,3 +687,149 @@
- `src/features/exmn/model/exmn-detail-table.ts`
- `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx`
## 35. 2026-07-31 지도보기 이동과 조사결과 레이어·필터 연결
### 구현 내용
- 조사 상세현황의 `지도보기` 버튼은 별도 안내 대신 `/map`으로 이동한다.
- 이동 query에는 조사사업 ID, 사업차수, 시도·시군구, 조사유형, 선택 처리분구 ID·명칭을 포함한다.
- 지도 진입 시 시도·시군구 명칭을 공통 행정구역 코드로 해석하고 기존 `SN_INFTWT_PROC_SEARCH` 이벤트를 사용해 검색조건 표시와 지도 위치를 함께 맞춘다.
- 기존 CMS 레이어 응답 뒤에 화면 제어용 `조사결과` 루트와 `CCTV조사`, `맨홀조사` 하위 노드를 추가했다.
- 조사관리에서 진입한 조사유형은 최초 체크 상태로 표시한다.
- 레이어 설정은 사업차수, 구조적 상태등급, 운영적 상태등급만 제공하며 값 변경 즉시 조회한다.
- PPL과 MNHL은 독립된 EPSG:5179 GeoJSON 벡터 소스·레이어를 사용한다.
- 시도·시군구 명칭을 공통 코드 목록으로 해석하고 시군구코드 앞 5자리 `sggCd`를 레이어 API에 전달해 전국 중복 관로번호를 구분한다.
- 행정구역 코드 목록이 아직 준비되지 않았으면 전국 범위 요청을 보내지 않고 코드 상태 갱신 후 다시 조회한다.
- 필터 변경 중 응답 순서가 뒤바뀌어도 최신 요청만 반영하도록 조사유형별 요청 순번을 관리한다.
- 체크 해제 시 해당 조사 레이어와 벡터 소스를 제거한다.
### 화면·API 계약
| 구분 | 계약 |
|---|---|
| 지도 이동 | `/map?exmnBizId=...&ctpvNm=...&sggNm=...&exmnJobType=PPL|MNHL&bizCycl=...` |
| 레이어 조회 | `/sewerage-service/api/v1/exmnMap/layers` |
| 필터 | `jobType`, `sggCd`(앞 5자리), 행정구역명, 사업차수, 구조적 등급, 운영적 등급 |
### 2026-07-31 지도보기 초기 시군구 선택·대표 관로 확대 보완
- 조사관리의 지도보기 진입 시 시도 코드만 먼저 적용하지 않고, 전달된 시군구명에 대응하는 시군구코드까지 준비된 뒤 공통 지도 검색바에 전달한다.
- 공통 지도 검색바는 외부 선택 이벤트가 시군구 원천 목록 조회보다 먼저 도착해도 목록 로딩 후 현재 시도에 맞는 시군구 후보를 다시 구성한다.
- 최초 조사결과 레이어 조회 성공 시 진입한 조사유형의 첫 시설 한 건을 기준으로 최대 줌 18까지 한 번만 확대한다.
- 이후 사용자의 지도 이동이나 필터 재조회에서는 초기 자동 확대를 반복하지 않는다.
| 필터 결합 | 구조·운영 등급 동시 입력 시 `AND` |
| 좌표계 | GeoJSON `EPSG:5179` |
| 레이어 선택 | 기존 지도 선택 모듈에 PPL 선·MNHL 점 레이어 등록 |
### 수정 파일
- `src/features/exmn/ui/ExmnStatusDetailPanel.tsx`
- `app/(map-layout)/map/page.tsx`
- `app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx`
- `app/(map-layout)/map/components/exmn/exmnMapApi.ts`
- `app/(map-layout)/map/components/exmn/exmnMapTypes.ts`
### 남은 작업
- 지도에서 조사 관로·맨홀을 선택했을 때 기존 시설정보와 분리된 오른쪽 `조사관리` 탭을 연다.
- 조사관리 탭에 구조적·운영적 상태등급과 조사보고서 링크를 표시한다.
- 조사보고서 링크는 백엔드의 저장된 페이지 범위 및 부분 PDF 조회 API와 연결한다.
## 2026-07-31 지도 우측 조사관리 탭·관로 보고서 연결
- 조사결과 GeoJSON 피처를 선택하면 기존 일반 시설 탭이 아닌 `조사관리(관로번호)` 전용 탭을 생성한다.
- 조사관리 탭은 기존 `FeatureClickEventPopup`과 관로·맨홀 입력정보 컴포넌트를 재사용한다.
- 기존 입력정보 바로 아래에 조사 결과 영역을 주입해 사업명, 사업차수, 처리분구, 조사유형, 보고서번호, 구조적·운영적 결함 등급을 표시한다.
- 조사관리 전용 탭에서는 일반 시설용 보수 및 갱신·청소 및 준설·점검 및 진단 영역을 숨긴다.
- PPL 조사결과에는 `조사보고서 보기` 버튼을 표시하고 관로별 부분 PDF API를 호출한다.
- PDF 요청 전에 새 창을 먼저 열어 브라우저 팝업 차단을 최소화하고 응답 Blob URL을 새 창에 연결한다.
- 관로 보고서 조회 실패 시 공통 Blob 오류 문자열 대신 백엔드의 사용자 안내 메시지만 표시한다.
- MNHL 조사보고서는 판별 기준 협의 전까지 버튼을 제공하지 않으며, 별도의 보류 안내 문구도 화면에 표시하지 않는다.
### 수정 파일
- `app/(map-layout)/map/page.tsx`
- `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx`
- `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`
- `app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx`
- `app/(map-layout)/map/components/popupComponent/smh.tsx`
- `app/(map-layout)/map/components/common/index.ts`
- `src/features/exmn/api/exmn-business-api.ts`
### 2026-07-31 지도 조사결과 패널 시각 통일
- 지도 우측 조사관리 탭의 `조사 결과` 제목, 여백, 표 라벨 셀, 글꼴과 글자 크기를 기존 `입력정보`의 `VerticalTable` 규칙과 동일하게 적용했다.
- 조사보고서 버튼은 기존 입력정보 헤더의 버튼 높이와 글꼴 규칙을 따르면서 다운로드 계열의 초록색을 유지했다.
- PPL 조사유형과 레이어의 사용자 표시 명칭을 `CCTV조사`에서 `관로조사`로 통일했다.
- 기존 DB 레이어명이 `CCTV조사`인 환경도 계속 탐색할 수 있도록 이전 명칭을 호환 키로 유지하고, 화면에는 `관로조사`로 정규화한다.
- 조사결과 레이어 트리의 관로 심볼은 노란색 선(`#facc15`)으로 표시한다.
- 맨홀 심볼은 기존 맨홀 분류의 파랑·빨강·초록·갈색·주황·회색과 겹치지 않는 보라색을 사용하되, 기존 `SWL_MANH_PS_BLUE.png`의 맨홀 형태와 음영을 그대로 유지하는 색상 변형 아이콘으로 통일했다.
- 선택 레이어 설정 카드의 조사유형 표식도 관로조사는 노란색(`#facc15`), 맨홀조사는 보라색(`#8b5cf6`)으로 표시해 레이어 범례·지도 피처와 색상 의미를 통일했다.
- 지도 우측 상세 패널의 다중 선택 탭은 `관로조사(관로번호)`와 `맨홀조사(관로번호)` 형식으로 표시하고, 관로 탭은 노란색 계열, 맨홀 탭은 보라색 계열로 구분한다.
- 선택 레이어 설정 카드의 사업차수·상태등급 입력 폭은 `54px`로 조정해 카드 안의 정보 밀도와 우측 여백을 보정했다.
- 선택 레이어 설정의 사업차수·구조적 상태등급·운영적 상태등급을 변경할 때 설정 컴포넌트가 다시 렌더링되어도 내부 세로 스크롤 위치를 저장·복원해 사용자가 보던 설정 카드 위치를 유지한다.
- 맨홀조사 지도 피처도 등급색 단순 원형 대신 레이어 트리와 동일한 보라색 맨홀 아이콘을 사용한다.
- 지도 피처의 선택 영역은 조사유형 탭·범례 색상과 별개로 기존 지도 공통 선택 효과를 그대로 사용한다.
- 레이어 ON/OFF로 설정 카드 수가 바뀌어도 선택 레이어 설정 영역은 고정 높이와 항상 확보된 세로 스크롤 공간을 유지해 인접 스크롤바의 표시·영역 크기가 흔들리지 않도록 했다.
#### 수정 파일
- `app/(map-layout)/map/page.tsx`
- `app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx`
- `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx`
- `app/(map-layout)/map/components/exmn/exmnMapTypes.ts`
- `public/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png`
### 2026-07-31 관로 조사보고서 WindowPopup 뷰어 전환
- 지도 우측 조사관리 탭의 `조사보고서 보기`는 PDF Blob URL을 브라우저 원본 탭에 직접 여는 대신 공통 WindowPopup `exmnPipeReport`를 연다.
- 전용 팝업은 기존 관로별 부분 PDF API를 호출하고 Blob URL을 내부 `iframe`에 표시한다.
- 팝업을 닫거나 조회 대상을 변경하면 생성한 Blob URL을 해제해 브라우저 메모리가 누적되지 않도록 했다.
- 조회 실패 시 새 탭을 닫고 `alert`를 띄우던 방식 대신 WindowPopup 본문에 백엔드 사용자 안내 메시지를 표시한다.
- 백엔드 API와 DB 계약은 변경하지 않았다.
#### 수정 파일
- `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx`
- `src/features/windowPopup/model/popupDefinitions.ts`
- `src/features/windowPopup/registry.tsx`
- `src/features/windowPopup/ui/ExmnPipeReportPopupContent.tsx`
### 2026-07-31 결과등록 검토 오류 중복 안내 제거
- 파일 데이터 검토 실패 사유는 해당 파일 행의 실패 상태와 하단 `데이터 검토 내역`에만 표시한다.
- 같은 검토 오류를 팝업 상단 전역 빨간 알림으로 다시 표시하던 중복 안내를 제거했다.
- 파일 미선택, 사업정보 조회, 최종 저장처럼 하단 검토 내역으로 대신할 수 없는 오류는 기존 상단 안내를 유지한다.
- `데이터 검토 내역` 헤더 우측의 보조 문구를 제거해 검토 결과에 시선이 집중되도록 정리했다.
- 결과등록 WindowPopup 기본 높이를 `500px`에서 `650px`로 확대해 최초 진입 시 하단 검토 내역이 잘리지 않도록 보정했다.
## 36. 2026-07-31 최종 기능 점검과 문서 정합성
### 점검 결과
- 처리분구 CRUD, 조사사업 조회·등록·수정, 조사·보고서 양식 다운로드, 결과등록, PPL/MNHL 종합집계와 상세조회, 본단위구간, 구조·운영 결함별 조회, 다중 시트 다운로드는 실제 API 기준으로 연결돼 있다.
- 조사 상세현황 지도보기는 사업·행정구역·조사유형 정보를 `/map`으로 전달하고, 지도에서 관로·맨홀 레이어와 독립 필터를 사용한다.
- 지도 우측 탭은 `관로조사(번호)`·`맨홀조사(번호)`로 구분하며 관로는 노란색 계열, 맨홀은 보라색 계열로 표시한다.
- 관로 조사보고서는 공통 WindowPopup 내부 PDF 뷰어로 표시하고, 맨홀 보고서는 판별 기준 확정 전까지 버튼을 표시하지 않는다.
- 지도 피처 선택 강조는 조사유형 범례 색을 덮어쓰지 않고 기존 공통 지도 선택 효과를 사용한다.
### 정적 점검 보완 결과
- `ExmnSurveyResultSection.tsx`의 무효 Tailwind 유틸리티 `!p-0r`를 `!p-0`으로 바로잡아 조사결과 카드 내부 여백 의도가 적용되도록 했다.
- 백엔드 맨홀 공간조회는 관로와 동일하게 시군구코드 앞 5자리를 사용하도록 보완했다.
- `ExmnMapLayerServiceTest`의 맨홀 Repository 모킹을 실제 서비스 호출 메서드와 `sggCd=41450` 조건에 맞췄다.
### 검증 범위
- 프론트·백엔드 `git diff --check`는 공백 오류 없이 통과했다.
- 긴 프론트 전체 빌드와 Gradle 컴파일·테스트는 사용자 요청과 기존 장시간 실행 이력에 따라 수행하지 않았다.
- 코드 정적 보완은 완료했으며 로그인 브라우저에서 지도 관로·맨홀 선택, 필터, 탭, 관로 부분 PDF를 한 번에 회귀 확인해야 한다.
## 37. 2026-07-31 관로 조사보고서 팝업 선분기
- 기존에는 `조사보고서 보기` 클릭 즉시 WindowPopup을 열고 팝업 내부에서 부분 PDF API를 호출했다.
- 지도 GeoJSON의 `reportAvailable` 속성을 먼저 확인하도록 변경했다.
- 관로 조사보고서 첨부파일이 없으면 WindowPopup을 만들지 않고 `등록된 관로 조사보고서가 없습니다.` alert만 표시한다.
- 첨부파일이 있으면 기존 `exmnPipeReport` WindowPopup을 열어 부분 PDF를 조회한다.
- 첨부는 있지만 선택 관로의 페이지 분석·추출이 실패하는 경우에는 기존처럼 팝업 내부에 백엔드 오류를 표시한다.
@@ -0,0 +1,35 @@
# SN-노후하수관로정비사업 FE 통합구현기록
## 입력 근거
사용자 작업지시 Excel, 공통 통합설계, `/oper/oldPp`, 조사관리 업로드, `apps/cs/banner/register`를 확인했다.
## 2026-08-03 구현
- `src/features/obsltPpl`에 API, 타입, 목록, 표형 등록·수정 화면을 추가했다.
- `app/(with-base-layout)/oper/obsltPplBiz`에 목록·등록·동적 수정 경로를 추가했다.
- 목록에서 Excel 전체 항목과 사업구분·시군구코드를 표시하고 직접 구성 Table을 가로 스크롤 영역에 배치했다.
- 목록 액션을 `조회`, `등록`, `업로드`, `엑셀다운로드`로 구성하고 삭제는 수정 화면으로 이동했다.
- 업로드 성공 시 전체·정밀조사·정비사업 저장 건수를 표시하고 목록을 재조회한다.
- 기존 공통 또는 타 앱 소스는 변경하지 않았다.
## BE 공유 계약
기준 API는 `/api/v1/obsltPplBiz`이며 목록은 Spring Page, 업로드는 FE 파싱 결과 JSON 배열, 다운로드는 XLSX Blob이다.
## 남은 리스크
로그인·메뉴 권한이 구성된 통합환경 브라우저 검증이 필요하다.
## 2026-08-03 지역 검색 유지보수
- 목록의 시도·시군 입력을 공통 `SelectBox`로 교체했다.
- SN 앱 공통 Zustand store의 `ctpv`, `sgg`, `fetchCtpv`, `fetchSgg`를 사용하고 선택 시 기존 검색 API 계약에 맞는 한글명을 설정한다.
- 선택한 시도의 코드 앞 두 자리를 기준으로 시군구 후보를 제한하고 시도 변경 시 기존 시군구 선택을 초기화한다.
## 2026-08-04 Excel 업로드 FE 파싱 전환
- `apps/wr /matMng/coMat/ruseCaseMng` 흐름을 참고하여 `.xlsx` 읽기와 값 파싱 책임을 FE로 이동했다.
- `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다.
- `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다.
- 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다.
@@ -0,0 +1,83 @@
# SN 도시침수지도(정보제공포털) 목록보기 통합 구현 기록
## 1. 문서 목적
- 도시침수지도 목록보기와 일반 지도 표출의 현재 구현 상태를 기능 단위로 통합 기록한다.
- 본 문서에는 2026-08-05 maintenance에서 확정된 WMS·SLD 전환을 최종 일반 표출 기준으로 기록한다.
## 2. 입력 근거
- 공통 설계: `IIPBS/docs/작업계획/SN-도시침수지도-정보제공포털-목록보기/001-de-SN-도시침수지도-정보제공포털-목록보기-설계.md`
- WMS·SLD 매뉴얼: `IIPBS/docs/작업계획/공통-GeoServer-WMS-SLD적용/001-공통-GeoServer-WMS-SLD적용-매뉴얼.md`
- 레이어 설정: `sm_bs_sn.tb_sn_lyr_stng`
- 대상 레이어: `tb_ms_flood_dngr_map`
## 3. 공통 계약
- 화면 노출 빈도는 30년, 50년, 80년이며 100년은 추가하지 않는다.
- 부모 59·60·58은 각각 빈도 `030`, `050`, `080`의 실제 WMS 레이어다.
- 자식 15건은 `actlLyrYn='N'`으로 유지하며 침수심도 SLD와 범례의 메타데이터로 사용한다.
- 백엔드는 기존 레이어 설정 응답 계약을 유지한다.
## 4. 구현 라운드 요약
### 4.1 2026-08-04 목록보기 구현
- 도시침수지도 목록 팝업, 검색·페이징·정렬, 그룹 범위 이동을 구현했다.
- 목록 데이터와 범위 조회는 대용량 원본 GeoJSON을 직접 반환하지 않는 구조로 정리했다.
- 이 라운드의 일반 지도 WFS 복원 기록은 2026-08-05 WMS 전환으로 대체한다.
### 4.2 2026-08-05 일반 지도 WMS·SLD 전환
- `gis.wms-sld.js`
- 부모 번호로 자식 레이어 설정 5건을 선별하고 순서를 정렬한다.
- 자식 필터에서 `N330~N334`를 추출해 SLD `Rule` 5개를 만든다.
- RGBA를 16진 색상과 opacity로 변환하고 XML 특수문자를 이스케이프한다.
- 정확히 5건의 유효 설정이 아닐 때 `null`을 반환해 잘못된 스타일 표출을 차단한다.
- `gis.layer.js`
- TileWMS 요청에 `CQL_FILTER`, `SLD_BODY`, PNG 투명 배경을 적용한다.
- SLD 내부 레이어명은 접두사 없는 `tb_ms_flood_dngr_map`을 사용한다.
- WMS 레이어의 `drawOrdr`, `minViewSize`, 문자열 ID를 정상 반영한다.
- `gis.core.js`
- SLD 검증 실패로 WMS 레이어가 생성되지 않은 경우 지도 추가를 안전하게 중단한다.
- `GisTree.js`
- 실제 WMS 부모를 자동 재생 대상에 포함한다.
- 숨겨진 자식은 범례 메타데이터로 유지하되 실제 레이어와 자동 재생 대상에서는 제외한다.
## 5. 기능별 현재 상태
| 기능 | 현재 상태 |
|---|---|
| 목록 팝업 | 기존 구현 유지 |
| 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 |
| 일반 지도 통신 | 빈도 부모 3개 TileWMS |
| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 |
| 침수심도 스타일 | 자식 5건 기반 동적 SLD |
| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 |
| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 |
| 기존 다른 레이어 | 기존 WFS 경로 유지 |
## 6. BE 공유 사항
- 신규 API나 응답 필드는 필요하지 않다.
- 레이어 설정 DTO의 `flterSql`, `upperLyrSn`, `lnColor`, `polgnColor`, `lnWdth`를 계속 제공해야 한다.
- DB 전환 DML은 공통 `IIPBS/docs/DB작업.sql`에서 관리한다.
## 7. 검증 요약
- SLD 단위검사 통과
- ESLint 오류 0건
- `@common/business_components` 빌드 성공
- 개발 DB 부모 3건·자식 15건 정합성 정상
- 30·50·80년 ON/OFF 정상
- 30년 WMS 요청과 SLD 규칙 5개 정상
## 8. 남은 리스크
- TileWMS는 논리 레이어 1개당 화면 타일 수만큼 `GetMap`을 요청하므로 HTTP 요청 개수를 정확히 3개로 판단하면 안 된다.
- 운영 프록시의 URL 길이 제한과 GeoServer `SLD_BODY` 허용 설정은 운영 반영 시 재확인한다.
- 전체 SN Next 빌드는 개발 서버 종료 후 별도 수행한다.
## 9. 흡수한 기존 문서
- 별도 SN 기능 문서는 없었다. 공통 2026-08-04 바이브코딩 기록의 목록보기 핵심 상태를 본 문서에 요약했다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

@@ -9,6 +9,7 @@ import type {
ExmnImportValidationResult,
ExmnJobType,
ExmnPage,
ExmnPipeReportAvailability,
ExmnSectionSummaryRow,
ExmnTemplateResult,
PplFacility,
@@ -285,7 +286,25 @@ export const downloadExmnReportTemplate = async (): Promise<Blob> =>
export const downloadExmnBusinessReport = async (attachmentId: string): Promise<Blob> =>
getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`);
/** 저장된 페이지 범위를 이용해 선택 관로의 조사보고서 구간만 PDF로 조회한다. */
export const downloadExmnPipeReport = async (
exmnBizId: string,
pipeNumber: string,
): Promise<Blob> =>
getBlobData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}`,
);
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
/** 팝업 생성 전에 선택 관로번호가 등록된 관로 PDF에 포함되는지 확인한다. */
export const getExmnPipeReportAvailability = async (
exmnBizId: string,
pipeNumber: string,
): Promise<ExmnPipeReportAvailability> =>
getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}/availability`,
) as Promise<ExmnPipeReportAvailability>;
export const updateExmnAttachments = async (
exmnBizId: string,
attachments: Partial<Record<ExmnFileType, ExmnAttachmentKey>>,
@@ -8,7 +8,7 @@ export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024;
/** 조사사업 파일별 화면 표시명과 허용 확장자다. */
export const EXMN_FILE_RULES: Record<ExmnFileType, { label: string; extensions: string[] }> = {
EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx", "xlsm"] },
EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx"] },
PPL_EXMN_PDF: { label: "CCTV 조사 결과(관로)", extensions: ["pdf"] },
MNHL_EXMN_PDF: { label: "CCTV 조사 결과(맨홀)", extensions: ["pdf"] },
BIZ_REPORT: { label: "사업보고서", extensions: ["pdf"] },
@@ -115,6 +115,12 @@ export interface ExmnImportCommitResult {
summaryCount: number;
}
/** 선택 관로의 조사보고서 팝업을 열 수 있는지 확인한 결과다. */
export interface ExmnPipeReportAvailability {
available: boolean;
message?: string | null;
}
/** 처리분구별 PPL 조사 집계 행이다. */
export interface PplSummaryRow {
trtdstId: string;
@@ -27,6 +27,10 @@ import type { TrtdstSettingDto } from "../model/trtdst-types";
const DOWNLOAD_BUTTON_CLASS =
"rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
const FORM_LABEL_CLASS =
"shrink-0 whitespace-nowrap text-xl font-medium text-gray-600";
const FORM_INPUT_CLASS =
"h-[3.2rem] text-base text-label";
type Notice = { type: "success" | "error" | "info"; text: string } | null;
@@ -334,7 +338,7 @@ export function ExmnBusinessRegisterPopupPage({
<Button
type="button"
color="white"
size="xs"
size="sm"
className="sn-popup-header-button"
disabled={isLoading || formLocked}
onClick={() => void handleSave()}
@@ -362,11 +366,12 @@ export function ExmnBusinessRegisterPopupPage({
<div className="space-y-4 p-4">
<div className="flex flex-nowrap items-center gap-5">
<div className="flex items-center gap-2">
<Label className="shrink-0 whitespace-nowrap text-base">
<Label className={FORM_LABEL_CLASS}>
<span className="text-red-500">* </span>사업차수
</Label>
<Input
style={{ width: "3.5rem", flex: "none" }}
className={FORM_INPUT_CLASS}
style={{ width: "6rem", flex: "none" }}
type="text"
inputMode="numeric"
maxLength={3}
@@ -379,17 +384,17 @@ export function ExmnBusinessRegisterPopupPage({
</div>
<SelectBox
label="사업년도"
labelClass="shrink-0 whitespace-nowrap text-base"
labelClass={FORM_LABEL_CLASS}
className="flex items-center gap-2"
selectClass="w-[6.5rem] bg-white"
selectClass="h-[3.2rem] w-28 rounded-lg bg-white text-label"
selectArray={getStartYearArray({ placeholder: "선택" })}
value={bizYr}
readOnly={formLocked}
onSelectValue={(value) => setBizYr(String(value))}
/>
<CtpvSggPrcsFcltSelectBox
compact
className="!mr-0 !justify-start"
selectValueClassName="text-label"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(nextCtpvCd) => {
@@ -409,8 +414,9 @@ export function ExmnBusinessRegisterPopupPage({
}}
/>
<div className="flex items-center gap-2">
<Label className="shrink-0 whitespace-nowrap text-base"><span className="text-red-500">* </span>정밀조사 연장</Label>
<Label className={FORM_LABEL_CLASS}><span className="text-red-500">* </span>정밀조사 연장</Label>
<Input
className={FORM_INPUT_CLASS}
style={{ width: "6rem", flex: "none" }}
type="text"
inputMode="decimal"
@@ -419,12 +425,12 @@ export function ExmnBusinessRegisterPopupPage({
disabled={formLocked}
onChange={(event) => setPrcisExmnPrlg(event.target.value)}
/>
<span className="text-sm text-gray-500">km</span>
<span className="text-base font-medium text-gray-600">km</span>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-20 shrink-0 text-base"><span className="text-red-500">* </span>사업명</Label>
<Input style={{ maxWidth: "480px" }} value={bizNm} maxLength={200} disabled={formLocked} onChange={(event) => setBizNm(event.target.value)} />
<Label className={`w-20 ${FORM_LABEL_CLASS}`}><span className="text-red-500">* </span>사업명</Label>
<Input className={FORM_INPUT_CLASS} style={{ maxWidth: "480px" }} value={bizNm} maxLength={200} disabled={formLocked} onChange={(event) => setBizNm(event.target.value)} />
</div>
</div>
</section>
@@ -403,7 +403,7 @@ export function ExmnRegistrationPage() {
/>
<div className="mr-8 flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">사업명</Label>
<Input className="h-[3.2rem] w-52 text-base" value={draftFilters.bizNm} onChange={(event) => setDraftFilters((current) => ({ ...current, bizNm: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(); }} />
<Input className="h-[3.2rem] w-[32rem] text-base" value={draftFilters.bizNm} onChange={(event) => setDraftFilters((current) => ({ ...current, bizNm: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(); }} />
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleSearch()}>조회</Button>
@@ -26,8 +26,12 @@ import type {
ExmnImportValidationResult,
} from "../model/exmn-business-types";
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-3 py-1.5 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
const POPUP_BUTTON_SIZE_CLASS =
"h-10 w-[9.6rem] rounded-sm px-4 !text-[1.2rem] font-medium";
const POPUP_ACTION_BUTTON_CLASS =
`${POPUP_BUTTON_SIZE_CLASS} !text-white disabled:!text-white`;
const FIND_BUTTON_CLASS =
"inline-flex h-10 cursor-pointer items-center justify-center rounded-sm bg-blue-600 px-3 !text-[1.2rem] font-medium !text-white hover:bg-blue-700";
const FILE_TYPES: ExmnFileType[] = [
"EXMN_EXCEL",
@@ -283,7 +287,7 @@ export function ExmnResultRegisterPopupPage({
...current,
[fileType]: [validationMessage],
}));
setNotice({ type: "error", text: validationMessage });
setNotice(null);
return;
}
@@ -303,16 +307,7 @@ export function ExmnResultRegisterPopupPage({
...current,
[fileType]: result.errors.map((item) => item.message),
}));
const visibleErrors = result.errors.slice(0, 5).map((item) => item.message);
if (result.errors.length > visibleErrors.length) {
visibleErrors.push(`외 ${result.errors.length - visibleErrors.length}건`);
}
setNotice(result.valid
? null
: {
type: "error",
text: `검토 오류 ${result.errors.length}건\n${visibleErrors.join("\n")}`,
});
setNotice(null);
} catch (error) {
const message = error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다.";
setValidationResults((current) => {
@@ -325,10 +320,7 @@ export function ExmnResultRegisterPopupPage({
...current,
[fileType]: [message],
}));
setNotice({
type: "error",
text: message,
});
setNotice(null);
} finally {
setIsLoading(false);
}
@@ -375,8 +367,8 @@ export function ExmnResultRegisterPopupPage({
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
size="sm"
className="sn-popup-header-button h-10 w-auto px-3 !text-[1.2rem]"
disabled={
isLoading
|| !excelValidation?.valid
@@ -392,7 +384,7 @@ export function ExmnResultRegisterPopupPage({
<section className="flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto px-4 pb-3 pt-3">
{notice && notice.type !== "success" && (
<div role="alert" className={`whitespace-pre-line rounded border px-4 py-3 text-sm ${
<div role="alert" className={`whitespace-pre-line rounded border px-4 py-3 text-[1.3rem] ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: notice.type === "success"
@@ -407,30 +399,30 @@ export function ExmnResultRegisterPopupPage({
>
<div className="flex min-w-0 flex-wrap items-center gap-x-7 gap-y-2">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">사업차수</span>
<span className="whitespace-nowrap text-[1.05rem] font-semibold text-foreground">
<span className="whitespace-nowrap text-xl font-medium text-gray-600">사업차수</span>
<span className="whitespace-nowrap text-[1.3rem] font-semibold text-black">
{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">사업년도</span>
<span className="whitespace-nowrap text-[1.05rem] font-semibold text-foreground">
<span className="whitespace-nowrap text-xl font-medium text-gray-600">사업년도</span>
<span className="whitespace-nowrap text-[1.3rem] font-semibold text-black">
{detail?.bizYr ?? businessSummary.bizYr ?? "-"}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">행정구역</span>
<span className="whitespace-nowrap text-xl font-medium text-gray-600">행정구역</span>
<span
className="max-w-[22rem] truncate text-[1.05rem] font-semibold text-foreground"
className="max-w-[22rem] truncate text-[1.3rem] font-semibold text-black"
title={`${detail?.ctpvNm ?? businessSummary.ctpvNm} ${detail?.sggNm ?? businessSummary.sggNm}`}
>
{detail?.ctpvNm ?? businessSummary.ctpvNm} {detail?.sggNm ?? businessSummary.sggNm}
</span>
</div>
<div className="flex min-w-[24rem] flex-1 items-center gap-2">
<span className="shrink-0 whitespace-nowrap text-[1.05rem] font-medium text-label">사업명</span>
<span className="shrink-0 whitespace-nowrap text-xl font-medium text-gray-600">사업명</span>
<span
className="min-w-0 truncate text-[1.05rem] font-semibold text-foreground"
className="min-w-0 truncate text-[1.3rem] font-semibold text-black"
title={detail?.bizNm ?? businessSummary.bizNm}
>
{detail?.bizNm ?? businessSummary.bizNm ?? "-"}
@@ -441,7 +433,7 @@ export function ExmnResultRegisterPopupPage({
<section className="overflow-hidden border border-border">
<div
className="grid items-center gap-3 bg-[#eaf3fb] px-3 py-2.5 text-center text-sm font-semibold text-title"
className="grid items-center gap-3 bg-[#eaf3fb] px-3 py-2.5 text-center text-[1.3rem] font-medium text-label"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<span>파일 구분</span><span>선택 파일</span><span>찾기</span><span>데이터 검토</span>
@@ -455,15 +447,15 @@ export function ExmnResultRegisterPopupPage({
return (
<div
key={fileType}
className="grid items-center gap-3 border-t border-border px-3 py-3 text-sm"
className="grid items-center gap-3 border-t border-border px-3 py-3 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<label htmlFor={inputId} className="font-medium text-gray-700">
{(fileType === "PPL_EXMN_PDF" || fileType === "MNHL_EXMN_PDF") && <span className="text-red-500">* </span>}
<label htmlFor={inputId} className="font-medium text-gray-600">
{(fileType === "EXMN_EXCEL" || fileType === "PPL_EXMN_PDF" || fileType === "MNHL_EXMN_PDF") && <span className="text-red-500">* </span>}
{rule.label}
</label>
<div
className={`flex min-w-0 items-center gap-2 border px-3 py-1.5 ${
className={`flex min-w-0 items-center gap-2 rounded-md border px-3 py-1.5 ${
validationStatus === "success"
? "border-emerald-300 bg-emerald-50 font-semibold text-emerald-700"
: validationStatus === "error"
@@ -492,11 +484,11 @@ export function ExmnResultRegisterPopupPage({
disabled={isLoading}
onChange={(event) => handleFileChange(fileType, event.target.files)}
/>
<label htmlFor={inputId} className="inline-flex cursor-pointer rounded-sm bg-blue-600 px-3 py-1.5 font-medium text-white hover:bg-blue-700">찾기</label>
<label htmlFor={inputId} className={FIND_BUTTON_CLASS}>찾기</label>
</div>
<div className="text-center">
{canValidate ? (
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleValidate(fileType)}>데이터 검토</Button>
<Button color="green" className={POPUP_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleValidate(fileType)}>데이터 검토</Button>
) : <span className="text-gray-400">-</span>}
</div>
</div>
@@ -506,9 +498,8 @@ export function ExmnResultRegisterPopupPage({
{visibleValidationTypes.length > 0 && (
<section className="overflow-hidden border border-border" aria-label="데이터 검토 내역">
<div className="flex items-center justify-between bg-[#eaf3fb] px-3 py-2.5">
<h2 className="text-sm font-semibold text-title">데이터 검토 내역</h2>
<span className="text-xs text-label">파일별로 실제 확인한 항목입니다.</span>
<div className="flex items-center bg-[#eaf3fb] px-3 py-2.5">
<h2 className="text-[1.3rem] font-medium text-label">데이터 검토 내역</h2>
</div>
{visibleValidationTypes.map((fileType) => {
const status = fileValidationStatuses[fileType]!;
@@ -523,10 +514,10 @@ export function ExmnResultRegisterPopupPage({
return (
<div
key={fileType}
className="grid items-start gap-3 border-t border-border px-3 py-2.5 text-sm"
className="grid items-start gap-3 border-t border-border px-3 py-2.5 text-[1.3rem] text-label"
style={{ gridTemplateColumns: "180px 88px minmax(0, 1fr)" }}
>
<span className="font-medium text-gray-700">
<span className="font-medium text-gray-600">
{EXMN_FILE_RULES[fileType].label}
</span>
<span className={`inline-flex w-fit rounded-full px-2.5 py-1 text-xs font-semibold ${
@@ -538,9 +529,9 @@ export function ExmnResultRegisterPopupPage({
</span>
<div className="min-w-0">
{status === "success" && countText && (
<p className="mb-1 font-medium text-foreground">{countText}</p>
<p className="mb-1 font-medium text-label">{countText}</p>
)}
<p className="leading-5 text-gray-600">
<p className="leading-5 text-label">
{guide?.checkedItems.join(" · ")}
</p>
{guide?.pendingNote && (
@@ -560,7 +551,7 @@ export function ExmnResultRegisterPopupPage({
)}
{uploadStatus && (
<p className="text-right text-sm text-blue-700">{uploadStatus}</p>
<p className="text-right text-[1.3rem] text-blue-700">{uploadStatus}</p>
)}
</section>
</main>
@@ -241,8 +241,21 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
));
};
const showMapPendingNotice = () => {
setNotice({ type: "info", text: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다." });
/** 선택 사업의 행정구역·사업차수·현재 조사유형을 하수관망도 초기 조회 계약으로 전달한다. */
const openExmnMap = () => {
if (!business) return;
const query = new URLSearchParams({
exmnBizId: business.exmnBizId,
ctpvNm: business.ctpvNm,
sggNm: business.sggNm,
exmnJobType: jobType,
});
if (business.bizCycl != null) query.set("bizCycl", String(business.bizCycl));
if (selectedSummaryRow) {
query.set("trtdstId", selectedSummaryRow.trtdstId);
query.set("trtdstNm", selectedSummaryRow.trtdstNm);
}
window.location.assign(`/map?${query.toString()}`);
};
/** 현재 조사유형의 종합집계와 처리분구별 전체 상세 시트를 서버 XLSX로 내려받는다. */
@@ -359,7 +372,13 @@ export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps)
>
상세보기
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={showMapPendingNotice}>지도보기</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={openExmnMap}
>
지도보기
</Button>
<Button
color="green"
className={DOWNLOAD_BUTTON_CLASS}
@@ -66,7 +66,6 @@ const FIXED_COLUMNS = [
const REPORT_COLUMN_LEFT = 0;
const PIPE_COLUMN_LEFT = FIXED_COLUMNS[0].width;
const STICKY_COLUMN_COUNT = 2;
const PPL_FIXED_COLUMN_COUNT = 9;
const STICKY_DIVIDER_SHADOW = "2px 0 0 #b8c5d1";
const SEVERITY_COLUMN_WIDTH = 52;
type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"];
@@ -183,13 +182,8 @@ export function ExmnSurveyDetailPopupPage({
);
const selectedTrtdstNm = selectedDistrict?.trtdstNm
?? (selectedTrtdstId === trtdstId ? trtdstNm : "");
/** 관로는 등급을 본단위구간에서 표시하고, 맨홀 전체표만 구조·운영 등급 열을 추가한다. */
const fixedColumns = useMemo(
() => jobType === "PPL"
? FIXED_COLUMNS.slice(0, PPL_FIXED_COLUMN_COUNT)
: FIXED_COLUMNS,
[jobType],
);
/** 관로·맨홀 모두 확정 계산된 구조·운영 내부결함 등급을 공통 고정 열로 표시한다. */
const fixedColumns = FIXED_COLUMNS;
const summaryLabelColumnCount = 7;
const fixedInfoScrollThreshold = useMemo(
() => fixedColumns
@@ -236,7 +230,7 @@ export function ExmnSurveyDetailPopupPage({
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
);
}, [appliedDetailNumberKeyword, scopedRows]);
/** 본단위구간 최초 진입은 전체를 표시하고, 관리번호가 있으면 부분 일치로 좁힌다. */
/** 본단위구간 최초 진입은 전체를 표시하고, 관로번호가 있으면 부분 일치로 좁힌다. */
const unitRows = useMemo(() => {
if (!isUnitMode) return [];
const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR");
@@ -399,7 +393,7 @@ export function ExmnSurveyDetailPopupPage({
setNotice(null);
};
/** 모드를 바꾸면 이전 표의 관리번호·정렬·안내 상태를 초기화한다. */
/** 모드를 바꾸면 이전 표의 관로번호·정렬·안내 상태를 초기화한다. */
const handleDetailModeChange = (nextMode: ExmnPplDetailMode) => {
if (nextMode === detailMode) return;
setDetailMode(nextMode);
@@ -454,16 +448,12 @@ export function ExmnSurveyDetailPopupPage({
[rows],
);
/** 고정 열의 합산값이며 구조·운영 등급 합계는 맨홀 전체표에만 표시한다. */
/** 고정 열의 합산값이며 관로·맨홀 모두 구조·운영 등급 합계를 표시한다. */
const fixedSummaryValues = [
{ key: "extensionM", value: formatNumber(totals.extensionM) },
{ key: "drivingDistanceM", value: formatNumber(totals.drivingDistanceM) },
...(jobType === "MNHL"
? [
{ key: "structuralGrade", value: formatNumber(totals.structuralGrade) },
{ key: "operationalGrade", value: formatNumber(totals.operationalGrade) },
]
: []),
{ key: "structuralGrade", value: formatNumber(totals.structuralGrade) },
{ key: "operationalGrade", value: formatNumber(totals.operationalGrade) },
];
/** 현재 보이는 구조·운영 결함 열만 기준으로 대·중·소 소계와 항목별 합계를 계산한다. */
@@ -582,12 +572,10 @@ export function ExmnSurveyDetailPopupPage({
extensionM: "연장(m)",
drivingDistanceM: "주행거리(m)",
};
if (jobType === "MNHL") {
Object.assign(detailHeaderMapping, {
structuralGrade: "구조적 내부결함",
operationalGrade: "운영적 내부결함",
});
}
Object.assign(detailHeaderMapping, {
structuralGrade: "구조적 내부결함",
operationalGrade: "운영적 내부결함",
});
Object.assign(detailHeaderMapping, {
anomalyL: "이상항목 합계-대",
anomalyM: "이상항목 합계-중",
@@ -613,12 +601,10 @@ export function ExmnSurveyDetailPopupPage({
extensionM: row.extensionM,
drivingDistanceM: row.drivingDistanceM,
};
if (jobType === "MNHL") {
Object.assign(result, {
structuralGrade: row.structuralGrade,
operationalGrade: row.operationalGrade,
});
}
Object.assign(result, {
structuralGrade: row.structuralGrade,
operationalGrade: row.operationalGrade,
});
Object.assign(result, {
anomalyL: anomalyTotals.L,
anomalyM: anomalyTotals.M,
@@ -654,16 +640,14 @@ export function ExmnSurveyDetailPopupPage({
extensionM: totals.extensionM,
drivingDistanceM: totals.drivingDistanceM,
};
if (jobType === "MNHL") {
Object.assign(subtotalRow, {
structuralGrade: totals.structuralGrade,
operationalGrade: totals.operationalGrade,
});
Object.assign(totalRow, {
structuralGrade: totals.structuralGrade,
operationalGrade: totals.operationalGrade,
});
}
Object.assign(subtotalRow, {
structuralGrade: totals.structuralGrade,
operationalGrade: totals.operationalGrade,
});
Object.assign(totalRow, {
structuralGrade: totals.structuralGrade,
operationalGrade: totals.operationalGrade,
});
Object.assign(subtotalRow, {
anomalyL: defectSummary.anomalySubtotals.L,
anomalyM: defectSummary.anomalySubtotals.M,
@@ -797,11 +781,11 @@ export function ExmnSurveyDetailPopupPage({
</label>
<label className="sn-popup-field" style={{ width: "300px" }}>
<div className="sn-popup-field-label">관리번호</div>
<div className="sn-popup-field-label">관로번호</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={detailNumberKeyword}
placeholder="관리번호 입력"
placeholder="관로번호 입력"
onChange={(event) => setDetailNumberKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
@@ -899,7 +883,7 @@ export function ExmnSurveyDetailPopupPage({
<div className="flex shrink-0 items-center gap-7 whitespace-nowrap text-[1.05rem] text-label">
{isUnitMode ? (
<>
<span><strong className="mr-2 text-foreground">관리번호</strong>{appliedDetailNumberKeyword || "전체"}</span>
<span><strong className="mr-2 text-foreground">관로번호</strong>{appliedDetailNumberKeyword || "전체"}</span>
<span><strong className="mr-2 text-foreground">단위구간</strong>{unitSectionCount.toLocaleString("ko-KR")}개</span>
</>
) : (
@@ -927,7 +911,7 @@ export function ExmnSurveyDetailPopupPage({
rows={unitRows}
emptyMessage={
appliedDetailNumberKeyword
? "입력한 관리번호에 해당하는 본단위구간이 없습니다."
? "입력한 관로번호에 해당하는 본단위구간이 없습니다."
: "조회된 본단위구간이 없습니다."
}
/>
@@ -0,0 +1,60 @@
import type {
ObsltPplBusiness,
ObsltPplBusinessSaveRequest,
ObsltPplImportResult,
ObsltPplPage,
} from "../model/obslt-ppl-types";
import {
deleteData,
getBlobData,
getData,
postRawJson,
putData,
} from "@/components/lib/api";
const API_PATH = "v1/obsltPplBiz";
/** 공통 응답 래퍼가 있는 JSON 응답에서 data를 안전하게 꺼낸다. */
const unwrapData = <T>(body: unknown): T => {
if (body && typeof body === "object" && "data" in body) {
return (body as { data: T }).data;
}
return body as T;
};
/** 목록 검색조건과 페이지 번호로 활성 사업 목록을 조회한다. */
export const getObsltPplBusinesses = async (params: URLSearchParams): Promise<ObsltPplPage> =>
getData(`${API_PATH}?${params.toString()}`) as Promise<ObsltPplPage>;
/** 수정 화면에서 사업 상세 한 건을 조회한다. */
export const getObsltPplBusiness = async (id: string): Promise<ObsltPplBusiness> =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<ObsltPplBusiness>;
/** 등록 화면의 입력값으로 사업 한 건을 생성한다. */
export const createObsltPplBusiness = async (
request: ObsltPplBusinessSaveRequest,
): Promise<ObsltPplBusiness> =>
unwrapData<ObsltPplBusiness>(await postRawJson(API_PATH, request));
/** 수정 화면의 입력값으로 사업 한 건을 갱신한다. */
export const updateObsltPplBusiness = async (
id: string,
request: ObsltPplBusinessSaveRequest,
): Promise<ObsltPplBusiness> =>
putData(`${API_PATH}/${encodeURIComponent(id)}`, request) as Promise<ObsltPplBusiness>;
/** 수정 화면에서 선택 사업을 논리삭제한다. */
export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
await deleteData(`${API_PATH}/${encodeURIComponent(id)}`);
};
/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */
export const uploadObsltPplExcel = async (
rows: ObsltPplBusinessSaveRequest[],
): Promise<ObsltPplImportResult> =>
unwrapData<ObsltPplImportResult>(await postRawJson(`${API_PATH}/excel/upload`, rows));
/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */
export const downloadObsltPplExcel = async (): Promise<Blob> =>
getBlobData(`${API_PATH}/excel/download`);
@@ -0,0 +1,187 @@
import * as XLSX from "xlsx";
import type { ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
type BusinessType = ObsltPplBusinessSaveRequest["bizSe"];
type SheetContract = {
sheetName: BusinessType;
businessCodeHeader: string;
extensionHeader: string;
progressHeader: string;
startDateHeader: string;
};
const SHEET_CONTRACTS: SheetContract[] = [
{
sheetName: "정밀조사",
businessCodeHeader: "정밀조사사업코드",
extensionHeader: "조사연장(km)",
progressHeader: "정밀조사추진현황",
startDateHeader: "착수(예정)일자(yyyy.mm.dd)",
},
{
sheetName: "정비사업",
businessCodeHeader: "정비사업사업코드",
extensionHeader: "정비연장(km)",
progressHeader: "정비사업추진현황",
startDateHeader: "착공(예정)일자(yyyy.mm.dd)",
},
];
const DATA_START_ROW_INDEX = 7;
const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
/** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */
const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim();
/** SheetJS 셀의 화면 표시값을 원본 양식과 같은 문자열로 읽는다. */
const cellText = (sheet: XLSX.WorkSheet, rowIndex: number, columnIndex: number) => {
const cell = sheet[XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex })];
return cell ? XLSX.utils.format_cell(cell).trim() : "";
};
/** 새 업로드 양식의 공통·업무별 핵심 헤더가 정확한 위치에 있는지 확인한다. */
const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
const expectedHeaders: Array<[number, number, string]> = [
[5, 1, "순번"], [5, 2, "정밀조사차수"], [5, 3, contract.businessCodeHeader],
[5, 4, "시도"], [5, 5, "시군"], [5, 6, "환경청"], [5, 7, "사업명"],
[5, 8, "추진현황"], [5, 9, "착수년도"], [5, 10, "준공(예정)년도"],
[5, 11, contract.extensionHeader], [6, 11, "계획연장(km)"],
[6, 12, "상반기(예상)연장(km)"], [6, 13, "잔여연장(km)"],
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
[5, 17, "사업추진부진사유"], [5, 18, "사업담당자(지자체)"],
[6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"], [6, 21, "공단의견"],
];
for (const [rowIndex, columnIndex, expected] of expectedHeaders) {
const actual = normalizeHeader(cellText(sheet, rowIndex, columnIndex));
if (actual !== normalizeHeader(expected)) {
const address = XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex });
throw new Error(
`${contract.sheetName} 시트 ${address} 헤더가 올바르지 않습니다. 기대값: '${expected}', 실제값: '${actual || "(빈 값)"}'`,
);
}
}
};
/** 빈 셀과 대시 표기를 API의 null 값으로 정규화한다. */
const optionalText = (value: string): string | null => {
const normalized = value.trim();
return !normalized || normalized === "-" ? null : normalized;
};
/** 필수 문자열을 검사하고 시트·행이 포함된 오류를 제공한다. */
const requiredText = (value: string, sheetName: string, rowNumber: number, fieldName: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(`${sheetName} 시트 ${rowNumber}행의 ${fieldName} 값은 필수입니다.`);
return normalized;
};
/** 순번 셀을 안전한 정수로 변환한다. */
const optionalInteger = (value: string, sheetName: string, rowNumber: number): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "");
if (!normalized) return null;
const parsed = Number(normalized);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${sheetName} 시트 ${rowNumber}행의 순번 값이 정수가 아닙니다.`);
}
return parsed;
};
/** 연장 셀의 콤마와 km 단위를 제거하고 유한한 숫자로 변환한다. */
const optionalDecimal = (value: string, sheetName: string, rowNumber: number, fieldName: string): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "").replace(/km/gi, "").trim();
if (!normalized) return null;
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) {
throw new Error(`${sheetName} 시트 ${rowNumber}행의 ${fieldName} 값이 숫자가 아닙니다.`);
}
return parsed;
};
/** 연도 셀은 DB 계약에 맞게 최초 4자리 연도를 보존한다. */
const yearText = (value: string): string | null => {
const normalized = optionalText(value);
if (!normalized) return null;
return normalized.match(/(19|20)\d{2}/)?.[0] ?? normalized.slice(0, 4);
};
/** 혼합 날짜 표기에서 숫자를 추출하고 DB 최대 길이 8자로 제한한다. */
const dateText = (value: string): string | null => {
const normalized = optionalText(value);
if (!normalized) return null;
const digits = normalized.replace(/[^0-9]/g, "");
return (digits || normalized).slice(0, 8);
};
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
).every((value) => !value.trim());
/** 한 업무 시트의 데이터행을 JSON 저장 요청으로 변환한다. */
const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBusinessSaveRequest[] => {
validateHeaders(sheet, contract);
const range = XLSX.utils.decode_range(sheet["!ref"] ?? "A1:A1");
const result: ObsltPplBusinessSaveRequest[] = [];
for (let rowIndex = DATA_START_ROW_INDEX; rowIndex <= range.e.r; rowIndex += 1) {
if (isEmptyDataRow(sheet, rowIndex)) continue;
const values = Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
);
const rowNumber = rowIndex + 1;
const bizSeq = optionalInteger(values[0], contract.sheetName, rowNumber);
const originalBizCd = optionalText(values[2]);
const fallbackType = contract.sheetName === "정밀조사" ? "S" : "M";
result.push({
bizSe: contract.sheetName,
bizSeq,
prcisExmnCycl: optionalText(values[1]),
bizCd: originalBizCd ?? `UNASSIGNED-${fallbackType}-${bizSeq ?? rowNumber}`,
sggCd: null,
ctpvNm: optionalText(values[3]),
sigunNm: optionalText(values[4]),
envoNm: optionalText(values[5]),
bizNm: requiredText(values[6], contract.sheetName, rowNumber, "사업명"),
prplPrst: optionalText(values[7]),
bgngYr: yearText(values[8]),
cmcnYr: yearText(values[9]),
planPrlg: optionalDecimal(values[10], contract.sheetName, rowNumber, "계획연장(km)"),
frhfyrExpcPrlg: optionalDecimal(values[11], contract.sheetName, rowNumber, "상반기(예상)연장(km)"),
rmndPrlg: optionalDecimal(values[12], contract.sheetName, rowNumber, "잔여연장(km)"),
evdncDcmntSbmsnStts: optionalText(values[13]),
bgngYmd: dateText(values[14]),
cmcnYmd: dateText(values[15]),
bizPrplDlyRsnCn: optionalText(values[16]),
bizPicNm: optionalText(values[17]),
bizPicCnpl: optionalText(values[18]),
rmrkCn: optionalText(values[19]),
pbcpOpnnCn: optionalText(values[20]),
});
}
return result;
};
/** ArrayBuffer workbook을 검증·파싱하여 두 시트의 JSON 저장 요청을 반환한다. */
export const parseObsltPplWorkbook = (workbookData: ArrayBuffer | Uint8Array): ObsltPplBusinessSaveRequest[] => {
const workbook = XLSX.read(workbookData, { type: "array", cellDates: false });
const rows = SHEET_CONTRACTS.flatMap((contract) => {
const sheet = workbook.Sheets[contract.sheetName];
if (!sheet) throw new Error(`필수 시트가 없습니다: ${contract.sheetName}`);
return parseSheet(sheet, contract);
});
if (rows.length === 0) throw new Error("업로드할 사업 자료가 없습니다.");
return rows;
};
/** 사용자가 선택한 xlsx 파일을 브라우저에서 읽고 JSON 요청 목록으로 변환한다. */
export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSaveRequest[]> => {
if (!file.name.toLowerCase().endsWith(".xlsx")) throw new Error("xlsx 파일만 업로드할 수 있습니다.");
return parseObsltPplWorkbook(await file.arrayBuffer());
};
@@ -0,0 +1,80 @@
type RegionOption = {
text: string;
val: string | number;
};
const normalizeRegionText = (value?: string | null) => String(value ?? "").replace(/\s+/g, " ").trim();
/** 시군구명에서 상위 시만 대표명으로 추출한다. */
export const extractObsltPplRepresentativeSigunName = (value?: string | null) => {
const normalized = normalizeRegionText(value);
if (!normalized) return "";
const cityMatch = normalized.match(/^(.+?시)\s*(.+)$/);
return cityMatch?.[1] ?? normalized;
};
/** 목록과 상세 저장에 사용할 대표 시군구명을 정규화한다. */
export const normalizeObsltPplSigunName = (value?: string | null) => {
const representative = extractObsltPplRepresentativeSigunName(value);
return representative || normalizeRegionText(value);
};
/** 선택된 시도 기준으로 대표 시군구 한 건씩만 노출할 옵션 목록을 만든다. */
export const buildObsltPplRepresentativeSggOptions = (
sggOptions: RegionOption[],
ctpvCd: string,
) => {
const representativeMap = new Map<string, RegionOption>();
sggOptions
.filter((option) => {
const optionCode = String(option.val ?? "").trim();
return ctpvCd === "0" || optionCode.startsWith(ctpvCd.slice(0, 2));
})
.forEach((option) => {
const representativeName = normalizeObsltPplSigunName(option.text);
if (!representativeName) return;
const existing = representativeMap.get(representativeName);
if (!existing || String(option.val).localeCompare(String(existing.val), "ko") < 0) {
representativeMap.set(representativeName, {
text: representativeName,
val: String(option.val),
});
}
});
return [...representativeMap.values()].sort((left, right) => left.text.localeCompare(right.text, "ko"));
};
/** 저장된 시도명으로 공통 시도 코드값을 다시 찾는다. */
export const findObsltPplCtpvCodeByName = (ctpvOptions: RegionOption[], ctpvNm?: string | null) =>
String(
ctpvOptions.find((option) => normalizeRegionText(option.text) === normalizeRegionText(ctpvNm))?.val ?? "0",
);
/** 저장된 시군구명 또는 코드에 해당하는 대표 시군구 옵션을 찾는다. */
export const findObsltPplRepresentativeSggCode = (
sggOptions: RegionOption[],
ctpvCd: string,
sigunNm?: string | null,
sggCd?: string | null,
) => {
const normalizedSigunName = normalizeObsltPplSigunName(sigunNm);
const representativeOptions = buildObsltPplRepresentativeSggOptions(sggOptions, ctpvCd);
if (normalizedSigunName) {
const matchedByName = representativeOptions.find((option) => option.text === normalizedSigunName);
if (matchedByName) return String(matchedByName.val);
}
const normalizedSggCd = String(sggCd ?? "").trim();
if (!normalizedSggCd) return "0";
const matchedRawOption = sggOptions.find((option) => String(option.val).trim() === normalizedSggCd);
if (!matchedRawOption) return "0";
return String(
representativeOptions.find((option) => option.text === normalizeObsltPplSigunName(matchedRawOption.text))?.val ?? "0",
);
};
@@ -0,0 +1,51 @@
/** 노후하수관로 정밀조사·정비사업 한 건의 API 계약이다. */
export interface ObsltPplBusiness {
obsltSwagePplBizSn: number;
bizSe: "정밀조사" | "정비사업";
bizSeq?: number | null;
prcisExmnCycl?: string | null;
bizCd: string;
sggCd?: string | null;
ctpvNm?: string | null;
sigunNm?: string | null;
envoNm?: string | null;
bizNm: string;
prplPrst?: string | null;
bgngYr?: string | null;
cmcnYr?: string | null;
planPrlg?: number | null;
frhfyrExpcPrlg?: number | null;
rmndPrlg?: number | null;
evdncDcmntSbmsnStts?: string | null;
bgngYmd?: string | null;
cmcnYmd?: string | null;
bizPrplDlyRsnCn?: string | null;
bizPicNm?: string | null;
bizPicCnpl?: string | null;
rmrkCn?: string | null;
pbcpOpnnCn?: string | null;
regDt?: string | null;
mdfcnDt?: string | null;
}
/** 등록·수정 요청에서는 서버 생성 일련번호와 감사 컬럼을 제외한다. */
export type ObsltPplBusinessSaveRequest = Omit<
ObsltPplBusiness,
"obsltSwagePplBizSn" | "regDt" | "mdfcnDt"
>;
/** Spring Data 페이지 응답의 화면 사용 필드다. */
export interface ObsltPplPage {
content: ObsltPplBusiness[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
/** Excel 교체 업로드 결과 건수를 표시한다. */
export interface ObsltPplImportResult {
importedCount: number;
precisionSurveyCount: number;
maintenanceCount: number;
}
@@ -0,0 +1,211 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow } from "@common/components/ui";
import {
createObsltPplBusiness,
deleteObsltPplBusiness,
getObsltPplBusiness,
updateObsltPplBusiness,
} from "../api/obslt-ppl-api";
import {
buildObsltPplRepresentativeSggOptions,
findObsltPplCtpvCodeByName,
findObsltPplRepresentativeSggCode,
normalizeObsltPplSigunName,
} from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
type FormKey = keyof ObsltPplBusinessSaveRequest;
type FormState = Record<FormKey, string>;
const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"];
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; required?: boolean; placeholder?: string }> = [
{ key: "bizSe", label: "사업구분", required: true }, { key: "bizSeq", label: "순번", type: "number" },
{ key: "prcisExmnCycl", label: "정밀조사차수" }, { key: "bizCd", label: "사업코드", required: true },
{ key: "envoNm", label: "환경청" }, { key: "bizNm", label: "사업명", required: true },
{ key: "prplPrst", label: "추진현황" }, { key: "bgngYr", label: "착수년도", placeholder: "YYYY" },
{ key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획연장(km)", type: "number" },
{ key: "frhfyrExpcPrlg", label: "상반기(예상)연장(km)", type: "number" }, { key: "rmndPrlg", label: "잔여연장(km)", type: "number" },
{ key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYYMMDD" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
{ key: "bizPicNm", label: "담당자 이름" }, { key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" },
];
const NUMBER_KEYS = new Set<FormKey>(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
/** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */
const createEmptyForm = (): FormState => Object.fromEntries(
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [key, key === "bizSe" ? "정밀조사" : ""]),
) as FormState;
/** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */
const toForm = (business: ObsltPplBusiness): FormState => {
const form = createEmptyForm();
FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); });
form.sggCd = String(business.sggCd ?? "");
form.ctpvNm = String(business.ctpvNm ?? "");
form.sigunNm = normalizeObsltPplSigunName(business.sigunNm);
return form;
};
/** 숫자 입력의 빈 문자열은 null로, 나머지는 trim 문자열로 저장 요청을 만든다. */
const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries(
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => {
const value = form[key].trim();
return [key, NUMBER_KEYS.has(key) ? (value === "" ? null : Number(value)) : (value === "" ? null : value)];
}),
) as unknown as ObsltPplBusinessSaveRequest;
/** 배너 등록 화면의 표형 폼 패턴을 적용한 별도 등록·수정 화면이다. */
export function ObsltPplFormPage({ id }: { id?: string }) {
const router = useRouter();
const isModify = Boolean(id);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<FormState>(createEmptyForm);
const [loading, setLoading] = useState(isModify);
const [notice, setNotice] = useState("");
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const ctpvOptions = useMemo(() => [{ text: "전체", val: "0", hidden: true }, ...ctpv], [ctpv]);
const representativeSggOptions = useMemo(
() => [{ text: "전체", val: "0", hidden: true }, ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd)],
[ctpvCd, sgg],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
if (!ctpv.length || !sgg.length) return;
const resolvedCtpvCd = findObsltPplCtpvCodeByName(ctpv, form.ctpvNm);
const nextCtpvCd = resolvedCtpvCd !== "0" ? resolvedCtpvCd : ctpvCd;
const nextSggCd = findObsltPplRepresentativeSggCode(sgg, nextCtpvCd, form.sigunNm, form.sggCd);
if (nextCtpvCd !== ctpvCd) setCtpvCd(nextCtpvCd);
if (nextSggCd !== sggCd) setSggCd(nextSggCd);
}, [ctpv, ctpvCd, form.ctpvNm, form.sggCd, form.sigunNm, sgg, sggCd]);
useEffect(() => {
if (!id) return;
setLoading(true);
getObsltPplBusiness(id).then((data) => setForm(toForm(data)))
.catch((error) => setNotice(error instanceof Error ? error.message : "상세정보를 조회하지 못했습니다."))
.finally(() => setLoading(false));
}, [id]);
/** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */
const handleSave = async () => {
if (!form.bizSe || !form.bizCd.trim() || !form.bizNm.trim()) { setNotice("사업구분, 사업코드, 사업명은 필수입니다."); return; }
if (ctpvCd === "0" || sggCd === "0" || !form.ctpvNm.trim() || !form.sigunNm.trim()) { setNotice("시도와 시군구를 선택해주세요."); return; }
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("순번과 물량은 숫자로 입력해주세요."); return; }
setLoading(true);
try {
if (id) await updateObsltPplBusiness(id, toRequest(form));
else await createObsltPplBusiness(toRequest(form));
window.alert(isModify ? "수정되었습니다." : "등록되었습니다.");
router.push("/oper/obsltPplBiz");
} catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); }
finally { setLoading(false); }
};
/** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */
const handleDelete = async () => {
if (!id || !window.confirm("삭제하시겠습니까?")) return;
setLoading(true);
try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push("/oper/obsltPplBiz"); }
catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); }
finally { setLoading(false); }
};
return (
<div className="card card-border !mt-6">
<LoadingOverlay isLoading={loading} />
<div className="card-header"><h3 className="text-2xl font-medium text-label">노후하수관로 사업 {isModify ? "수정" : "등록"}</h3></div>
<div className="card">
<Table variant="secondary">
<colgroup><col width="18%" /><col width="82%" /></colgroup>
<TableBody>
<TableRow>
<TableHead data-required>시군구 선택</TableHead>
<TableCell className="border">
<div className="flex flex-wrap items-center gap-3">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="w-[180px] rounded-lg bg-white"
selectArray={ctpvOptions}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setForm((current) => ({
...current,
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
sigunNm: "",
sggCd: "",
}));
}}
/>
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="w-[220px] rounded-lg bg-white"
selectArray={representativeSggOptions}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
const selectedOption = representativeSggOptions.find((option) => String(option.val) === nextSggCd);
setSggCd(nextSggCd);
setForm((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
sggCd: nextSggCd === "0" ? "" : nextSggCd,
}));
}}
/>
</div>
</TableCell>
</TableRow>
{FIELDS.map((field) => (
<TableRow key={field.key}>
<TableHead data-required={field.required || undefined}>{field.label}</TableHead>
<TableCell className="border">
{field.key === "bizSe" ? (
<select className="h-11 w-full rounded border bg-white px-3 text-[1.3rem]" value={form.bizSe} onChange={(e) => setForm({ ...form, bizSe: e.target.value })}><option value="정밀조사">정밀조사</option><option value="정비사업">정비사업</option></select>
) : field.type === "textarea" ? (
<textarea className="min-h-24 w-full rounded border bg-white p-3 text-[1.3rem]" value={form[field.key]} placeholder={field.placeholder} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} />
) : (
<Input className="!text-[1.3rem]" type={field.type === "number" ? "number" : "text"} step={field.type === "number" ? "any" : undefined} value={form[field.key]} placeholder={field.placeholder} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{notice && <p className="mt-3 text-sm text-red-600" role="alert">{notice}</p>}
<div className="mt-5 flex justify-end gap-2">
<Button size="sm" onClick={() => router.push("/oper/obsltPplBiz")}>목록</Button>
<Button size="sm" className="rounded-sm bg-blue-600 px-4 py-2 !text-white hover:bg-blue-700" onClick={() => void handleSave()}>저장</Button>
{isModify && <Button size="sm" className="rounded-sm bg-red-600 px-4 py-2 !text-white hover:bg-red-700" onClick={() => void handleDelete()}>삭제</Button>}
</div>
</div>
</div>
);
}
@@ -0,0 +1,391 @@
"use client";
import moment from "moment";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
import {
getObsltPplBusinesses,
uploadObsltPplExcel,
} from "../api/obslt-ppl-api";
import { parseObsltPplExcel } from "../lib/obslt-ppl-excel";
import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
const DEFAULT_LIST_SIZE = 10;
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
const BIZ_SE_OPTIONS = [
{ text: "전체", val: "0" },
{ text: "정밀조사", val: "정밀조사" },
{ text: "정비사업", val: "정비사업" },
];
const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
rowNumber: number;
};
const TABLE_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 4 },
{ id: "bizSe", title: "사업구분", widthPercent: 6 },
{ id: "bizSeq", title: "순번", widthPercent: 5 },
{ id: "prcisExmnCycl", title: "정밀조사차수", widthPercent: 6 },
{ id: "bizCd", title: "사업코드", widthPercent: 7 },
//{ id: "sggCd", title: "시군구코드", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 6 },
{ id: "sigunNm", title: "시군", widthPercent: 6 },
{ id: "envoNm", title: "환경청", widthPercent: 8 },
{ id: "bizNm", title: "사업명", widthPercent: 16 },
{ id: "prplPrst", title: "추진현황", widthPercent: 6 },
{ id: "bgngYr", title: "착수년도", widthPercent: 5 },
{ id: "cmcnYr", title: "준공(예정)년도", widthPercent: 6 },
{ id: "planPrlg", title: "계획연장(km)", widthPercent: 6 },
{ id: "frhfyrExpcPrlg", title: "상반기(예상)연장(km)", widthPercent: 8 },
{ id: "rmndPrlg", title: "잔여연장(km)", widthPercent: 6 },
{ id: "evdncDcmntSbmsnStts", title: "증빙서류\n제출여부", widthPercent: 6 },
{ id: "bgngYmd", title: "착수일자", widthPercent: 7 },
{ id: "cmcnYmd", title: "준공(예정)일자", widthPercent: 7 },
{ id: "bizPrplDlyRsnCn", title: "사업추진 부진사유", widthPercent: 26 },
{ id: "bizPicNm", title: "담당자 이름", widthPercent: 6 },
{ id: "bizPicCnpl", title: "담당자 연락처", widthPercent: 8 },
{ id: "rmrkCn", title: "비고", widthPercent: 10 },
{ id: "pbcpOpnnCn", title: "공단\n의견", widthPercent: 2 },
];
const EMPTY_PAGE: ObsltPplPage = {
content: [],
totalElements: 0,
totalPages: 0,
number: 0,
size: DEFAULT_LIST_SIZE,
};
const DATE_FIELD_SET = new Set<keyof ObsltPplBusiness>(["bgngYmd", "cmcnYmd"]);
/** 8자리 또는 구분자가 섞인 날짜 문자열을 화면·다운로드 공통 형식으로 정규화한다. */
const formatObsltPplDate = (value?: string | null) => {
if (!value) return "";
const normalized = String(value).trim();
if (!normalized) return "";
const digits = normalized.replace(/[^0-9]/g, "");
if (digits.length === 8) {
const parsedDate = moment(digits, "YYYYMMDD", true);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
}
const parsedDate = moment(normalized);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
};
/** 목록 표시값을 공통 포맷으로 정리한다. */
const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
...row,
sigunNm: normalizeObsltPplSigunName(row.sigunNm) || null,
bgngYmd: formatObsltPplDate(row.bgngYmd) || null,
cmcnYmd: formatObsltPplDate(row.cmcnYmd) || null,
});
/** 현재 조회조건 기준 전체 목록을 내려받기용 행으로 변환한다. */
const toExcelRow = (row: ObsltPplTableRow) => ({
NO: row.rowNumber,
사업구분: row.bizSe ?? "",
순번: row.bizSeq ?? "",
정밀조사차수: row.prcisExmnCycl ?? "",
사업코드: row.bizCd ?? "",
시도: row.ctpvNm ?? "",
시군: row.sigunNm ?? "",
환경청: row.envoNm ?? "",
사업명: row.bizNm ?? "",
추진현황: row.prplPrst ?? "",
착수년도: row.bgngYr ?? "",
"준공(예정)년도": row.cmcnYr ?? "",
"계획연장(km)": row.planPrlg ?? "",
"상반기(예상)연장(km)": row.frhfyrExpcPrlg ?? "",
"잔여연장(km)": row.rmndPrlg ?? "",
증빙서류제출여부: row.evdncDcmntSbmsnStts ?? "",
착수일자: formatObsltPplDate(row.bgngYmd),
"준공(예정)일자": formatObsltPplDate(row.cmcnYmd),
사업추진부진사유: row.bizPrplDlyRsnCn ?? "",
"담당자 이름": row.bizPicNm ?? "",
"담당자 연락처": row.bizPicCnpl ?? "",
비고: row.rmrkCn ?? "",
공단의견: row.pbcpOpnnCn ?? "",
});
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [page, setPage] = useState(1);
const [data, setData] = useState<ObsltPplPage>(EMPTY_PAGE);
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
const [resetPageNo, setResetPageNo] = useState(false);
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const [search, setSearch] = useState({ bizSe: "", ctpvNm: "", sigunNm: "", bizNm: "" });
const [appliedSearch, setAppliedSearch] = useState(search);
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd);
}, [ctpvCd, sgg]);
/** 공통 레이아웃에서 아직 지역 데이터를 불러오지 못한 경우 공통 store 조회를 보완한다. */
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */
const load = useCallback(async (targetPage = page, targetSearch = appliedSearch) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
setLoading(true);
try {
const result = await getObsltPplBusinesses(params);
setData(result);
setRows(result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - ((targetPage - 1) * DEFAULT_LIST_SIZE + index),
})));
} catch (error) {
setNotice(error instanceof Error ? error.message : "목록을 조회하지 못했습니다.");
setData(EMPTY_PAGE);
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
}, [appliedSearch, page]);
useEffect(() => {
void load();
}, [load]);
/** 선택한 xlsx를 브라우저에서 검증·파싱한 뒤 JSON으로 교체 업로드한다. */
const handleUpload = async (file?: File) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".xlsx")) {
setNotice("xlsx 파일만 업로드할 수 있습니다.");
return;
}
setLoading(true);
try {
const parsedRows = await parseObsltPplExcel(file);
const result = await uploadObsltPplExcel(parsedRows);
setNotice(
`총 ${result.importedCount.toLocaleString()}건을 저장했습니다. (정밀조사 ${result.precisionSurveyCount.toLocaleString()}건, 정비사업 ${result.maintenanceCount.toLocaleString()}건)`,
);
setResetPageNo(true);
setPage(1);
await load(1, appliedSearch);
} catch (error) {
setNotice(error instanceof Error ? error.message : "업로드하지 못했습니다.");
} finally {
setLoading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
/** 현재 조회조건 기준 전체 목록을 화면 포맷 그대로 xlsx로 내려받는다. */
const handleDownload = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: "0",
size: String(Math.max(data.totalElements, 1)),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(appliedSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const result = await getObsltPplBusinesses(params);
const exportRows = result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - index,
}));
const { utils, writeFileXLSX } = await import("xlsx");
const worksheet = utils.json_to_sheet(exportRows.map(toExcelRow));
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, "노후하수관로");
writeFileXLSX(workbook, "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
} catch (error) {
setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.");
} finally {
setLoading(false);
}
};
/** 입력 중 조건을 실제 조회조건으로 확정하고 첫 페이지를 다시 조회한다. */
const handleSearch = async () => {
const nextSearch = { ...search };
setNotice("");
setAppliedSearch(nextSearch);
setResetPageNo(true);
if (page === 1) {
await load(1, nextSearch);
return;
}
setPage(1);
};
return (
<>
<LoadingOverlay isLoading={loading} />
<SearchDiv>
<SearchFormLeft>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<div className="flex items-center">
<SelectBox
label="사업구분"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[120px] rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={search.bizSe}
onSelectValue={(value) => setSearch((current) => ({ ...current, bizSe: String(value) }))}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
sigunNm: "",
}));
}}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find((option) => String(option.val) === nextSggCd);
setSearch((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
}));
}}
/>
</div>
<div className="flex items-center">
<Label className="w-20 text-xl text-gray-600">사업명</Label>
<Input
className="w-[220px] !text-2xl"
value={search.bizNm}
onChange={(event) => setSearch((current) => ({ ...current, bizNm: event.target.value }))}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleSearch();
}
}}
/>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<div className="flex items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => void handleSearch()}>
조회
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => router.push("/oper/obsltPplBiz/register")}>
등록
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => fileInputRef.current?.click()}>
업로드
</Button>
<Button
className="rounded-sm bg-green-600 px-4 py-2 text-sm font-medium !text-white hover:bg-green-700"
size="sm"
onClick={() => void handleDownload()}
>
엑셀다운로드
</Button>
</div>
</SearchFormRight>
</SearchDiv>
{notice && (
<div role="status" className="mx-[1.2rem] mt-4 rounded border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700">
{notice}
</div>
)}
<ListDiv className="overflow-x-auto">
<div className="w-[100%]">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">
노후하수관로 정밀조사 및 정비사업 현황{" "}
<span className="text-base text-gray-500">({data.totalElements.toLocaleString("ko-KR")}건)</span>
</h3>
</div>
<UiTable<ObsltPplTableRow>
headName=""
headlist={TABLE_HEADLIST}
tableData={rows}
pageSize={DEFAULT_LIST_SIZE}
total={data.totalElements}
curPage={page}
widthFixed
onPageChange={setPage}
resetPageNo={resetPageNo}
cellClick={(index) => {
if (typeof index !== "number" || !rows[index]) return;
router.push(`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}`);
}}
renderCell={(row, headId) => {
if (!DATE_FIELD_SET.has(headId as keyof ObsltPplBusiness)) return undefined;
return formatObsltPplDate(row[headId as keyof ObsltPplTableRow] as string | null | undefined);
}}
/>
</div>
</ListDiv>
</>
);
}
@@ -11,7 +11,7 @@ export const windowPopupDefinitions = {
key: "exmnResultRegister",
title: "조사 결과 업로드",
width: 1000,
height: 500,
height: 650,
requiredParams: ["exmnBizId"],
},
exmnSurveyDetail: {
@@ -27,6 +27,13 @@ export const windowPopupDefinitions = {
width: 1280,
height: 900,
},
exmnPipeReport: {
key: "exmnPipeReport",
title: "관로 조사보고서",
width: 1200,
height: 850,
requiredParams: ["exmnBizId", "pipeNumber"],
},
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
@@ -129,6 +136,18 @@ export const windowPopupDefinitions = {
width: 1380,
height: 820,
},
sbmrgDngrMapPopup: {
key: "sbmrgDngrMapPopup",
title: "침수위험지도 목록",
width: 1160,
height: 720,
},
floodDngrMapPopup: {
key: "floodDngrMapPopup",
title: "도시침수지도 목록",
width: 1160,
height: 720,
},
pipeAnalysisPopup: {
key: "pipeAnalysisPopup",
title: "관로분석",
@@ -44,6 +44,8 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple",
MAP_SEARCH_FIT_EXTENT: "sn:popup:mapSearchFitExtent",
MAP_SEARCH_SHOW_FLOOD_GROUP: "sn:popup:mapSearchShowFloodGroup",
GIS_EDIT_SESSION_READY: "sn:popup:gisEditSessionReady",
GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync",
GIS_EDIT_SESSION_SHOW_INLINE: "sn:popup:gisEditSessionShowInline",
@@ -4,6 +4,7 @@ import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
import DredgingRegisterPopupContent from "./ui/DredgingRegisterPopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnPipeReportPopupContent from "./ui/ExmnPipeReportPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
@@ -18,6 +19,8 @@ import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SinkholePopupContent from "./ui/SinkholePopupContent";
import SbmrgDngrMapPopupContent from "./ui/SbmrgDngrMapPopupContent";
import FloodDngrMapPopupContent from "./ui/FloodDngrMapPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
@@ -55,6 +58,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.dredgingRegister,
component: DredgingRegisterPopupContent,
},
exmnPipeReport: {
...windowPopupDefinitions.exmnPipeReport,
component: ExmnPipeReportPopupContent,
},
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent,
@@ -111,6 +118,14 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.sinkholePopup,
component: SinkholePopupContent,
},
sbmrgDngrMapPopup: {
...windowPopupDefinitions.sbmrgDngrMapPopup,
component: SbmrgDngrMapPopupContent,
},
floodDngrMapPopup: {
...windowPopupDefinitions.floodDngrMapPopup,
component: FloodDngrMapPopupContent,
},
pipeAnalysisPopup: {
...windowPopupDefinitions.pipeAnalysisPopup,
component: PipeAnalysisPopupContent,
@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { downloadExmnPipeReport } from "@/features/exmn/api/exmn-business-api";
import type { WindowPopupContentProps } from "../model/types";
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
/** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */
const getReportErrorMessage = (error: unknown) => {
if (!(error instanceof Error)) {
return "관로 조사보고서를 불러오지 못했습니다.";
}
const backendMessage = error.message.match(/"message":"((?:\\.|[^"\\])*)"/)?.[1];
if (!backendMessage) return error.message;
try {
return JSON.parse(`"${backendMessage}"`) as string;
} catch {
return backendMessage;
}
};
/** 저장된 관로별 PDF 구간을 공통 WindowPopup 안에서 표시한다. */
export default function ExmnPipeReportPopupContent({
searchParams,
}: WindowPopupContentProps) {
const exmnBizId = getParam(searchParams.exmnBizId);
const pipeNumber = getParam(searchParams.pipeNumber);
const reportNo = getParam(searchParams.reportNo);
const [reportUrl, setReportUrl] = useState("");
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
let active = true;
let objectUrl = "";
setReportUrl("");
setErrorMessage("");
void downloadExmnPipeReport(exmnBizId, pipeNumber)
.then((blob) => {
if (!active) return;
const pdfBlob =
blob.type === "application/pdf"
? blob
: new Blob([blob], { type: "application/pdf" });
objectUrl = URL.createObjectURL(pdfBlob);
setReportUrl(objectUrl);
})
.catch((error) => {
if (!active) return;
console.warn("관로 조사보고서 조회 실패:", error);
setErrorMessage(getReportErrorMessage(error));
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [exmnBizId, pipeNumber]);
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로 조사보고서</h1>
<span className="text-[1.05rem] text-white">
관로번호 {pipeNumber}
{reportNo ? ` · 보고서번호 ${reportNo}` : ""}
</span>
</header>
<section className="min-h-0 flex-1 bg-container p-3">
{errorMessage ? (
<div className="flex h-full items-center justify-center rounded-sm border border-red-200 bg-red-50 p-6 text-center text-[1.1rem] text-red-700">
{errorMessage}
</div>
) : reportUrl ? (
<iframe
title={`${pipeNumber} 관로 조사보고서`}
src={reportUrl}
className="h-full w-full border border-border bg-white"
/>
) : (
<div className="flex h-full items-center justify-center rounded-sm border border-border bg-white text-[1.1rem] text-muted-foreground">
조사보고서를 불러오는 중입니다.
</div>
)}
</section>
</main>
);
}
@@ -0,0 +1,7 @@
import FloodDngrMapPopupPage from "@/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage";
import type { WindowPopupContentProps } from "../model/types";
/** 도시침수지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */
export default function FloodDngrMapPopupContent(_props: WindowPopupContentProps) {
return <FloodDngrMapPopupPage />;
}
@@ -0,0 +1,7 @@
import SbmrgDngrMapPopupPage from "@/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage";
import type { WindowPopupContentProps } from "../model/types";
/** 침수위험지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */
export default function SbmrgDngrMapPopupContent(_props: WindowPopupContentProps) {
return <SbmrgDngrMapPopupPage />;
}
@@ -346,7 +346,13 @@ export default React.memo(function GisTree({
// orderedLayers 순서를 우선 적용하면서, 타입 필터에 맞는 타겟 노드만 선별
const getTargetNodesInOrder = () => {
const target = nodes.filter((n) => n.data?.actlLyrYn === 'N' && layerTypes.includes(n.data?.lyrTyNm));
const target = nodes.filter((node) => {
const hasChildren = nodes.some((child) => child.parent === node.id);
const isWmsGroupLayer = node.data?.srvcNm === 'wms' && hasChildren;
return node.data?.exprsYn === 'Y'
&& layerTypes.includes(node.data?.lyrTyNm)
&& (node.data?.actlLyrYn === 'N' || isWmsGroupLayer);
});
// drawOrdr 값을 기준으로 오름차순 정렬
target.sort((a, b) => {
@@ -392,6 +392,7 @@ GisApp.core.prototype = {
*/
addWmsLayer: function (layerCodeObj) {
let layer = GisApp.Module.layer.getWmsLayer(layerCodeObj);
if (!layer) return null;
this.deleteLayer(layer.get('id'));
this.map.addLayer(layer);
return layer;
@@ -20,6 +20,11 @@ import GisOlStyle from './gis.style.js';
import Tile from 'ol/layer/Tile.js';
import TileWMS from 'ol/source/TileWMS.js';
import gisCore from './gis.core.js';
import {
buildFloodDngrSldBody,
FLOOD_DNGR_MAP_LAYER_TYPE,
getFloodDngrSldSettings,
} from './gis.wms-sld.js';
/**
* GisApp.layer
@@ -179,6 +184,7 @@ GisOlLayer.prototype = {
let url = GisApp.GEOSERVER_WMS_URL;
let toProj = GisApp.TO_PROJ;
const lyrTyNm = options.lyrTyNm;
const filter = String(options?.flterSql ?? '').trim();
let params = {};
params['LAYERS'] = this.storageNm + lyrTyNm;
params['SERVICE'] = 'WMS';
@@ -186,7 +192,21 @@ GisOlLayer.prototype = {
params['REQUEST'] = 'GetMap';
params['SRS'] = toProj;
params['FORMAT'] = 'image/png';
params['TRANSPARENT'] = true;
params['TILED'] = true;
params['STYLES'] = '';
if (filter) params['CQL_FILTER'] = filter;
if (lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE) {
const sldSettings = getFloodDngrSldSettings(GisApp.layerCode, options?.id);
const sldBody = buildFloodDngrSldBody(lyrTyNm, sldSettings);
if (!sldBody) {
console.warn('[GisApp] 도시침수지도 WMS SLD 생성에 필요한 침수심도 설정 5건이 유효하지 않습니다.', options);
return null;
}
params['SLD_BODY'] = sldBody;
}
const wmsLayer = new Tile({
...layerOptions,
id: layerOptions?.id,
@@ -196,8 +216,9 @@ GisOlLayer.prototype = {
serverType: 'geoserver',
}),
});
wmsLayer.setZIndex(options.draw0rdr);
wmsLayer.setMinZoom(options.minViewSize);
wmsLayer.set('id', String(layerOptions?.id));
wmsLayer.setZIndex(Number(options?.drawOrdr) || 1);
wmsLayer.setMinZoom(Number(options?.minViewSize) || 0);
this.layers.push(wmsLayer);
return wmsLayer;
},
@@ -29,9 +29,11 @@ GisOlSelect.prototype = {
dragBox: null,
dragPolygon: null,
selectedFeatures: [],
focusedBoundaryFeatures: null,
selectFeatureFlag: false,
initialize: function (map, options) {
this.map = map;
this.focusedBoundaryFeatures = new WeakSet();
Object.assign(this.defaults, options);
this.setInteraction();
this.pointerMoveEventHandler();
@@ -107,7 +109,8 @@ GisOlSelect.prototype = {
}
}
const geometryType = feature?.getGeometry?.()?.getType?.();
if (geometryType === 'LineString' || geometryType === 'MultiLineString') {
const focusedBoundary = me.focusedBoundaryFeatures.has(feature);
if (focusedBoundary || geometryType === 'LineString' || geometryType === 'MultiLineString') {
styles.push(...focusedLineStyle);
} else {
styles.push(baseStyle);
@@ -329,6 +332,7 @@ GisOlSelect.prototype = {
selectedFeatures.remove(feature);
});
selectedFeatures.clear();
this.focusedBoundaryFeatures = new WeakSet();
},
/**
* SELECT 이벤트 활성화
@@ -491,6 +495,20 @@ GisOlSelect.prototype = {
auto: auto,
});
},
/**
* 폴리곤을 선형 피처와 동일한 다중 경계 스타일로 강조한다.
*/
selectFocusedBoundaryFeature: function (feature, auto) {
let me = this;
me.focusedBoundaryFeatures.add(feature);
me.selectedFeatures.push(feature);
me.selectInteraction.dispatchEvent({
type: 'select',
selected: me.selectedFeatures,
deselected: [],
auto: auto,
});
},
};
export default GisOlSelect;
@@ -0,0 +1,103 @@
const FLOOD_DNGR_MAP_LAYER_TYPE = 'tb_ms_flood_dngr_map';
const FLOOD_DEPTH_ATTRIBUTE = 'sbmrg_dpthl_cd';
const FLOOD_DEPTH_CODES = ['N330', 'N331', 'N332', 'N333', 'N334'];
const escapeXml = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
const toHex = (value) => Math.round(value).toString(16).padStart(2, '0');
const parseCssColor = (value) => {
const normalized = String(value ?? '').trim();
const rgbaMatch = normalized.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/i);
if (rgbaMatch) {
const red = Number(rgbaMatch[1]);
const green = Number(rgbaMatch[2]);
const blue = Number(rgbaMatch[3]);
const opacity = rgbaMatch[4] == null ? 1 : Number(rgbaMatch[4]);
if ([red, green, blue].some((channel) => channel < 0 || channel > 255) || opacity < 0 || opacity > 1) {
return null;
}
return {
hex: `#${toHex(red)}${toHex(green)}${toHex(blue)}`,
opacity,
};
}
const hexMatch = normalized.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!hexMatch) return null;
const rawHex = hexMatch[1].length === 3
? hexMatch[1].split('').map((character) => character + character).join('')
: hexMatch[1];
return {
hex: `#${rawHex.toLowerCase()}`,
opacity: 1,
};
};
const getFloodDepthCode = (filterSql) => {
const match = String(filterSql ?? '').match(/\bsbmrg_dpthl_cd\s*=\s*'([^']+)'/i);
const depthCode = match?.[1]?.trim();
return FLOOD_DEPTH_CODES.includes(depthCode) ? depthCode : null;
};
const getLineWidth = (value) => {
const lineWidth = Number(value);
return Number.isFinite(lineWidth) && lineWidth >= 0 && lineWidth <= 20 ? lineWidth : null;
};
/**
* 빈도 부모 레이어 바로 아래에 있는 침수심도 설정만 선별한다.
* 부모는 WMS 요청을 담당하고, 반환된 자식 5건은 SLD 규칙의 필터와 색상 원본으로 사용한다.
*/
export const getFloodDngrSldSettings = (layerCode, parentLayerId) => Object.values(layerCode ?? {})
.filter((layerSetting) => (
String(layerSetting?.upperLyrSn ?? '') === String(parentLayerId ?? '')
&& layerSetting?.lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE
))
.sort((left, right) => Number(left?.sortOrdr ?? 0) - Number(right?.sortOrdr ?? 0));
/**
* DB의 침수심도 필터와 RGBA 스타일을 GeoServer가 해석할 수 있는 SLD 1.0 XML로 변환한다.
* 다섯 침수심도 코드나 색상값 중 하나라도 유효하지 않으면 잘못된 기본 스타일 표출을 막기 위해 null을 반환한다.
*/
export const buildFloodDngrSldBody = (layerName, layerSettings) => {
if (!Array.isArray(layerSettings) || layerSettings.length !== FLOOD_DEPTH_CODES.length) return null;
const settingsByDepthCode = new Map();
for (const layerSetting of layerSettings) {
const depthCode = getFloodDepthCode(layerSetting?.flterSql);
if (depthCode && !settingsByDepthCode.has(depthCode)) {
settingsByDepthCode.set(depthCode, layerSetting);
}
}
if (settingsByDepthCode.size !== FLOOD_DEPTH_CODES.length) return null;
const rules = [];
for (const depthCode of FLOOD_DEPTH_CODES) {
const layerSetting = settingsByDepthCode.get(depthCode);
const fillColor = parseCssColor(layerSetting?.polgnColor);
const strokeColor = parseCssColor(layerSetting?.lnColor);
const lineWidth = getLineWidth(layerSetting?.lnWdth);
if (!fillColor || !strokeColor || lineWidth == null) return null;
rules.push(
`<sld:Rule><sld:Name>${escapeXml(depthCode)}</sld:Name><sld:Title>${escapeXml(layerSetting?.lyrNm || depthCode)}</sld:Title>`
+ `<ogc:Filter><ogc:PropertyIsEqualTo><ogc:PropertyName>${FLOOD_DEPTH_ATTRIBUTE}</ogc:PropertyName><ogc:Literal>${escapeXml(depthCode)}</ogc:Literal></ogc:PropertyIsEqualTo></ogc:Filter>`
+ `<sld:PolygonSymbolizer><sld:Fill><sld:CssParameter name="fill">${fillColor.hex}</sld:CssParameter><sld:CssParameter name="fill-opacity">${fillColor.opacity}</sld:CssParameter></sld:Fill>`
+ `<sld:Stroke><sld:CssParameter name="stroke">${strokeColor.hex}</sld:CssParameter><sld:CssParameter name="stroke-opacity">${strokeColor.opacity}</sld:CssParameter><sld:CssParameter name="stroke-width">${lineWidth}</sld:CssParameter></sld:Stroke></sld:PolygonSymbolizer></sld:Rule>`
);
}
return `<?xml version="1.0" encoding="UTF-8"?>`
+ `<sld:StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:sld="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc">`
+ `<sld:NamedLayer><sld:Name>${escapeXml(layerName)}</sld:Name><sld:UserStyle><sld:FeatureTypeStyle>${rules.join('')}</sld:FeatureTypeStyle></sld:UserStyle></sld:NamedLayer>`
+ `</sld:StyledLayerDescriptor>`;
};
export { FLOOD_DNGR_MAP_LAYER_TYPE };
@@ -269,6 +269,19 @@ export const ProcSearchBar = ({
[sigunData],
);
// 외부 지도 진입 이벤트가 시군구 원천 조회보다 먼저 도착해도,
// 원천 목록이 준비되는 즉시 현재 시도에 맞는 선택 후보를 다시 구성한다.
useEffect(() => {
if (sidoValue === "0") {
setFilteredSigunData([{ text: "시군구", val: "0" }]);
return;
}
const prefix = sidoValue.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
}, [sidoValue, sigunData]);
const onSelectValue = useCallback(
(val: string, type: string) => {
if (type === "ctpvCd") {