조사결과 관로·맨홀 지도 레이어와 보고서 조회 연동
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -147,6 +170,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] = [
|
||||
@@ -156,6 +180,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 = "유하방향";
|
||||
@@ -180,6 +238,7 @@ type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
|
||||
type RightPanelTabCategory =
|
||||
| "sinkhole"
|
||||
| "pipe"
|
||||
| "exmnSurvey"
|
||||
| "treatmentFacility"
|
||||
| "treatmentZone"
|
||||
| "defaultFeature"
|
||||
@@ -190,6 +249,7 @@ type RightPanelTab = {
|
||||
kind: RightPanelTabKind;
|
||||
category: RightPanelTabCategory;
|
||||
displayLabel: string;
|
||||
surveyJobType?: ExmnMapJobType;
|
||||
parentTabId?: string | null;
|
||||
featureData: any;
|
||||
sinkholeDetail?: SinkholeDetail | null;
|
||||
@@ -223,6 +283,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]",
|
||||
@@ -241,6 +305,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[],
|
||||
@@ -268,7 +346,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;
|
||||
@@ -937,6 +1018,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";
|
||||
@@ -945,6 +1028,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"]) ||
|
||||
@@ -2521,6 +2614,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;
|
||||
@@ -2549,6 +2646,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] =
|
||||
@@ -2578,6 +2685,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으로 묶어 기존 지도 로직과 충돌을 줄인다.
|
||||
@@ -2620,6 +2751,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 스타일에 사용한다.
|
||||
@@ -2925,6 +3065,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) {
|
||||
@@ -3076,10 +3250,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);
|
||||
@@ -4096,6 +4273,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);
|
||||
@@ -4756,17 +5065,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,
|
||||
@@ -5182,11 +5499,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 [
|
||||
@@ -5201,7 +5524,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 ?? []),
|
||||
@@ -5250,6 +5579,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",
|
||||
@@ -5326,8 +5685,12 @@ const Map = () => {
|
||||
applySinkholeRadius,
|
||||
applySinkholeYearRange,
|
||||
draftAgedPipeFilters,
|
||||
exmnMnhlFilters,
|
||||
exmnPplFilters,
|
||||
handleApplyAgedPipeFilters,
|
||||
isAgedPipeChecked,
|
||||
isExmnMnhlChecked,
|
||||
isExmnPplChecked,
|
||||
isSinkholeChecked,
|
||||
isWaterDepthLayerActive,
|
||||
lastSinkholeSyncSummary,
|
||||
@@ -5337,12 +5700,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>
|
||||
@@ -5359,7 +5737,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>
|
||||
@@ -5368,7 +5750,11 @@ const Map = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [activeLayerSettingItems]);
|
||||
}, [
|
||||
activeLayerSettingItems,
|
||||
handleActiveLayerSettingScroll,
|
||||
restoreActiveLayerSettingScroll,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !gisMaps.current) return;
|
||||
@@ -6115,6 +6501,7 @@ const Map = () => {
|
||||
<FeatureClickEventPopup
|
||||
{...props}
|
||||
featureData={panelFeatureData}
|
||||
surveyMode={activeRightPanelTab?.category === "exmnSurvey"}
|
||||
onClose={() => {
|
||||
setGisEditDetailChangeId(null);
|
||||
closeActiveTab();
|
||||
@@ -7113,6 +7500,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();
|
||||
|
||||
@@ -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 분석 실패 | 기존 동작 유지 | 팝업 내부 백엔드 오류 표시 |
|
||||
| 브라우저 수동 검증 | 대기 | 보고서 등록·미등록 사업을 각각 선택해 확인 |
|
||||
|
||||
@@ -287,3 +287,52 @@
|
||||
- 관로 전체 상세표의 고정 열에 `구조적 내부결함`, `운영적 내부결함` 등급을 다시 포함한다.
|
||||
- 화면은 상세 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 성공·실패 흐름의 로그인 브라우저 회귀가 필요하다.
|
||||
|
||||
@@ -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를 조회한다.
|
||||
- 첨부는 있지만 선택 관로의 페이지 분석·추출이 실패하는 경우에는 기존처럼 팝업 내부에 백엔드 오류를 표시한다.
|
||||
|
||||
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>>,
|
||||
|
||||
@@ -115,6 +115,12 @@ export interface ExmnImportCommitResult {
|
||||
summaryCount: number;
|
||||
}
|
||||
|
||||
/** 선택 관로의 조사보고서 팝업을 열 수 있는지 확인한 결과다. */
|
||||
export interface ExmnPipeReportAvailability {
|
||||
available: boolean;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/** 처리분구별 PPL 조사 집계 행이다. */
|
||||
export interface PplSummaryRow {
|
||||
trtdstId: string;
|
||||
|
||||
@@ -287,7 +287,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
...current,
|
||||
[fileType]: [validationMessage],
|
||||
}));
|
||||
setNotice({ type: "error", text: validationMessage });
|
||||
setNotice(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,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) => {
|
||||
@@ -329,10 +320,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
...current,
|
||||
[fileType]: [message],
|
||||
}));
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: message,
|
||||
});
|
||||
setNotice(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -510,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">
|
||||
<div className="flex items-center bg-[#eaf3fb] px-3 py-2.5">
|
||||
<h2 className="text-[1.3rem] font-medium text-label">데이터 검토 내역</h2>
|
||||
<span className="text-base text-label">파일별로 실제 확인한 항목입니다.</span>
|
||||
</div>
|
||||
{visibleValidationTypes.map((fileType) => {
|
||||
const status = fileValidationStatuses[fileType]!;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const windowPopupDefinitions = {
|
||||
key: "exmnResultRegister",
|
||||
title: "조사 결과 업로드",
|
||||
width: 1000,
|
||||
height: 500,
|
||||
height: 650,
|
||||
requiredParams: ["exmnBizId"],
|
||||
},
|
||||
exmnSurveyDetail: {
|
||||
@@ -21,6 +21,13 @@ export const windowPopupDefinitions = {
|
||||
height: 720,
|
||||
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
|
||||
},
|
||||
exmnPipeReport: {
|
||||
key: "exmnPipeReport",
|
||||
title: "관로 조사보고서",
|
||||
width: 1200,
|
||||
height: 850,
|
||||
requiredParams: ["exmnBizId", "pipeNumber"],
|
||||
},
|
||||
oldPpUpload: {
|
||||
key: "oldPpUpload",
|
||||
title: "노후 하수관로 정비사업 등록",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { WindowPopupRegistryItem } from "./model/types";
|
||||
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
|
||||
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
|
||||
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";
|
||||
@@ -50,6 +51,10 @@ export const windowPopupRegistry = {
|
||||
...windowPopupDefinitions.exmnSurveyDetail,
|
||||
component: ExmnSurveyDetailPopupContent,
|
||||
},
|
||||
exmnPipeReport: {
|
||||
...windowPopupDefinitions.exmnPipeReport,
|
||||
component: ExmnPipeReportPopupContent,
|
||||
},
|
||||
oldPpUpload: {
|
||||
...windowPopupDefinitions.oldPpUpload,
|
||||
component: OldPpUploadPopupContent,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user