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:
@@ -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, "");
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user