조사 레이어 표출 및 조사결과 업로드 안내 개선
This commit is contained in:
@@ -14,6 +14,7 @@ type Props = {
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
statusMessage?: string;
|
||||
};
|
||||
|
||||
const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"];
|
||||
@@ -26,6 +27,7 @@ export default function ExmnLayerSettingPanel({
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
statusMessage = "",
|
||||
}: Props) {
|
||||
const updateFilter = (key: keyof ExmnMapFilters, value: string) => {
|
||||
onFiltersChange({
|
||||
@@ -46,6 +48,7 @@ export default function ExmnLayerSettingPanel({
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-[6px]">
|
||||
{statusMessage ? <p role="status" className="text-[1.1rem] text-muted-foreground">{statusMessage}</p> : null}
|
||||
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
|
||||
<span className={`flex items-center gap-1 ${labelClass}`}>
|
||||
<span
|
||||
|
||||
@@ -6,6 +6,8 @@ type ExmnLayerScope = {
|
||||
sggCd?: string;
|
||||
ctpvNm?: string;
|
||||
sggNm?: string;
|
||||
exmnBizId?: string;
|
||||
trtdstId?: string;
|
||||
};
|
||||
|
||||
/** 구버전 응답이 하류맨홀을 포함해도 업로드 관로조사의 상류맨홀 피처만 지도에 전달한다. */
|
||||
@@ -36,16 +38,19 @@ export const retainUpstreamManholeFeatures = (response: any) => {
|
||||
export const getExmnMapLayer = async (
|
||||
jobType: ExmnMapJobType,
|
||||
filters: ExmnMapFilters,
|
||||
scope: ExmnLayerScope,
|
||||
scope: ExmnLayerScope = {},
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
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 (scope.exmnBizId?.trim()) query.set("exmnBizId", scope.exmnBizId.trim());
|
||||
if (scope.trtdstId?.trim()) query.set("trtdstId", scope.trtdstId.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);
|
||||
|
||||
const response = await getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`);
|
||||
const response = await getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`, { signal });
|
||||
return jobType === "MNHL" ? retainUpstreamManholeFeatures(response) : response;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ExmnMapFilters = {
|
||||
|
||||
export type ExmnMapEntryContext = {
|
||||
exmnBizId: string;
|
||||
trtdstId: string;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
bizCycl: string;
|
||||
@@ -291,6 +292,7 @@ export const buildExmnMapEntryContext = (
|
||||
const rawJobType = String(searchParams.get("exmnJobType") ?? "").toUpperCase();
|
||||
return {
|
||||
exmnBizId: String(searchParams.get("exmnBizId") ?? "").trim(),
|
||||
trtdstId: String(searchParams.get("trtdstId") ?? "").trim(),
|
||||
ctpvNm: String(searchParams.get("ctpvNm") ?? "").trim(),
|
||||
sggNm: String(searchParams.get("sggNm") ?? "").trim(),
|
||||
bizCycl: String(searchParams.get("bizCycl") ?? "").replace(/[^0-9]/g, ""),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import GeoJSON from "ol/format/GeoJSON";
|
||||
import VectorLayer from "ol/layer/Vector";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import { getExmnMapLayer } from "./exmnMapApi";
|
||||
import {
|
||||
EMPTY_EXMN_MAP_FILTERS, EXMN_RESULT_PPL_LAYER_ID, EXMN_RESULT_MNHL_LAYER_ID,
|
||||
type ExmnMapEntryContext, type ExmnMapFilters, type ExmnMapJobType,
|
||||
} from "./exmnMapTypes";
|
||||
|
||||
type Options = {
|
||||
gisApp: any;
|
||||
mapRef: RefObject<any>;
|
||||
ready: boolean;
|
||||
treeData: any[];
|
||||
entry: ExmnMapEntryContext;
|
||||
pipeChecked: boolean;
|
||||
manholeChecked: boolean;
|
||||
pipeEnabled: boolean;
|
||||
manholeEnabled: boolean;
|
||||
pipeFilters: ExmnMapFilters;
|
||||
manholeFilters: ExmnMapFilters;
|
||||
pipeStyle: any;
|
||||
manholeStyle: any;
|
||||
};
|
||||
|
||||
type LayerState = {
|
||||
layer?: VectorLayer<any>;
|
||||
controller?: AbortController;
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
/** 전체 조사 레이어와 상세현황 위치 이동을 분리하며 유형별 요청을 취소/무효화한다. */
|
||||
export const useExmnResultMap = (options: Options) => {
|
||||
const { gisApp, mapRef, ready, treeData, entry, pipeChecked, manholeChecked,
|
||||
pipeEnabled, manholeEnabled, pipeFilters, manholeFilters, pipeStyle, manholeStyle } = options;
|
||||
// 지도 객체/요청 수명은 React 렌더링과 분리하고 관로·맨홀을 독립적으로 관리한다.
|
||||
const layers = useRef<Record<ExmnMapJobType, LayerState>>({ PPL: { sequence: 0 }, MNHL: { sequence: 0 } });
|
||||
const fittedContext = useRef("");
|
||||
const [messages, setMessages] = useState<Record<ExmnMapJobType, string>>({ PPL: "", MNHL: "" });
|
||||
|
||||
/** 체크 해제/언마운트 시 늦게 도착한 응답도 다시 표시되지 않게 한다. */
|
||||
const removeLayer = useCallback((job: ExmnMapJobType) => {
|
||||
const state = layers.current[job];
|
||||
state.sequence++;
|
||||
state.controller?.abort();
|
||||
state.controller = undefined;
|
||||
if (state.layer) {
|
||||
gisApp.Module.select?.removeLayer?.(state.layer);
|
||||
mapRef.current?.removeLayer?.(state.layer);
|
||||
}
|
||||
state.layer = undefined;
|
||||
}, [gisApp, mapRef]);
|
||||
|
||||
/** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */
|
||||
const loadLayer = useCallback(async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
removeLayer(job);
|
||||
const state = layers.current[job];
|
||||
const sequence = state.sequence;
|
||||
const controller = new AbortController();
|
||||
state.controller = controller;
|
||||
const active = () => state.sequence === sequence && !controller.signal.aborted;
|
||||
setMessages(current => ({ ...current, [job]: "조사결과를 불러오는 중입니다." }));
|
||||
try {
|
||||
const response = await getExmnMapLayer(job, filters, {}, controller.signal);
|
||||
if (!active()) return;
|
||||
const data = response?.data ?? response;
|
||||
const features = new GeoJSON().readFeatures(data, { dataProjection: "EPSG:5179", featureProjection: gisApp.TO_PROJ });
|
||||
const source = new VectorSource({ features });
|
||||
const layer = new VectorLayer({ source, zIndex: job === "PPL" ? 9988 : 9989,
|
||||
style: job === "PPL" ? pipeStyle : manholeStyle });
|
||||
layer.setProperties({ id: job === "PPL" ? EXMN_RESULT_PPL_LAYER_ID : EXMN_RESULT_MNHL_LAYER_ID,
|
||||
lyrNm: job === "PPL" ? "관로조사" : "맨홀조사",
|
||||
lyrTyNm: job === "PPL" ? "tb_ms_swage_ppl" : "tb_ms_swage_mhl", clickPsblYn: "Y", chcYn: "Y" });
|
||||
state.layer = layer;
|
||||
map.addLayer(layer);
|
||||
gisApp.Module.select?.addLayer?.(layer);
|
||||
const missing = Number(data?.missingGeometryCount ?? 0);
|
||||
const diagnostics = data?.diagnostics ?? {};
|
||||
const reasons = [
|
||||
["지역 확인 불가", diagnostics.regionUnresolvedCount], ["시설번호 없음", diagnostics.identifierMissingCount],
|
||||
["도형 없음", diagnostics.geometryNotFoundCount], ["도형 중복", diagnostics.ambiguousGeometryCount],
|
||||
].filter(([, count]) => Number(count) > 0).map(([label, count]) => `${label} ${count}건`).join(", ");
|
||||
const summary = features.length ? `${features.length.toLocaleString()}개 표시` : "표시할 조사결과가 없습니다.";
|
||||
const status = missing ? `${summary} · 공간정보 미연결 ${missing}건${reasons ? ` (${reasons})` : ""}` : summary;
|
||||
setMessages(current => ({ ...current, [job]: status }));
|
||||
|
||||
if (entry.jobType !== job || !entry.exmnBizId || !entry.trtdstId) return;
|
||||
// 위치 조회 실패가 정상적으로 조회된 기본 레이어를 지우지 않도록 별도 처리한다.
|
||||
try {
|
||||
const selectedResponse = await getExmnMapLayer(job, filters,
|
||||
{ exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId }, controller.signal);
|
||||
if (!active()) return;
|
||||
const selectedData = selectedResponse?.data ?? selectedResponse;
|
||||
const selectedFeatures = new GeoJSON().readFeatures(selectedData,
|
||||
{ dataProjection: "EPSG:5179", featureProjection: gisApp.TO_PROJ });
|
||||
if (!selectedFeatures.length) {
|
||||
setMessages(current => ({ ...current, [job]: `${status} · 선택한 처리분구에 현재 조건으로 표시할 공간정보가 없습니다.` }));
|
||||
return;
|
||||
}
|
||||
const selectedSource = new VectorSource({ features: selectedFeatures });
|
||||
// 별도 강조선을 추가하지 않고 기존 조사 레이어 색상으로 표시한다.
|
||||
const contextKey = `${entry.exmnBizId}:${entry.trtdstId}:${job}`;
|
||||
const extent = selectedSource.getExtent();
|
||||
if (fittedContext.current !== contextKey && extent.every(Number.isFinite)) {
|
||||
map.getView().fit(extent, { duration: 300, padding: [80, 420, 80, 80], maxZoom: 18,
|
||||
constrainResolution: true, nearest: true });
|
||||
fittedContext.current = contextKey;
|
||||
}
|
||||
} catch (error) {
|
||||
if (active()) setMessages(current => ({ ...current, [job]: `${status} · 선택 영역 조회 실패: ${error instanceof Error ? error.message : "다시 조회해 주세요."}` }));
|
||||
}
|
||||
} catch (error) {
|
||||
if (active()) setMessages(current => ({ ...current, [job]: `조회 실패: ${error instanceof Error ? error.message : "다시 조회해 주세요."}` }));
|
||||
}
|
||||
}, [entry.exmnBizId, entry.trtdstId, entry.jobType, gisApp, mapRef, pipeStyle, manholeStyle, removeLayer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && pipeChecked) void loadLayer("PPL", pipeEnabled ? pipeFilters : EMPTY_EXMN_MAP_FILTERS);
|
||||
else removeLayer("PPL");
|
||||
return () => removeLayer("PPL");
|
||||
}, [ready, pipeChecked, pipeEnabled, pipeFilters, treeData, loadLayer, removeLayer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && manholeChecked) void loadLayer("MNHL", manholeEnabled ? manholeFilters : EMPTY_EXMN_MAP_FILTERS);
|
||||
else removeLayer("MNHL");
|
||||
return () => removeLayer("MNHL");
|
||||
}, [ready, manholeChecked, manholeEnabled, manholeFilters, treeData, loadLayer, removeLayer]);
|
||||
|
||||
return { removeLayer, messages };
|
||||
};
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
} from "@common/business_components";
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { Button } from "@common/components";
|
||||
import {
|
||||
adjustWaterRiskStartDate,
|
||||
getWaterRiskMinStartDate,
|
||||
validateWaterRiskDateRange,
|
||||
} from "./waterRiskDateRange";
|
||||
|
||||
import {
|
||||
fetchWaterRiskAreas,
|
||||
@@ -153,11 +158,10 @@ const formatAnalysisBaseAt = (value?: string | null) => {
|
||||
/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */
|
||||
const buildDefaultResultFilters = (): ResultFilters => {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - 365);
|
||||
const endDateValue = formatLocalDate(endDate);
|
||||
return {
|
||||
startDate: formatLocalDate(startDate),
|
||||
endDate: formatLocalDate(endDate),
|
||||
startDate: getWaterRiskMinStartDate(endDateValue)!,
|
||||
endDate: endDateValue,
|
||||
ctpvCd: "ALL",
|
||||
sggCd: "ALL",
|
||||
areaId: "ALL",
|
||||
@@ -191,12 +195,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) =>
|
||||
|
||||
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
|
||||
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
// 팝업 최초 진입에만 지정 기간을 사용하고 초기화는 기본 조회조건을 새로 만든다.
|
||||
const initialFilters = useMemo(() => ({
|
||||
...buildDefaultResultFilters(),
|
||||
startDate: "2026-08-15",
|
||||
endDate: "2026-08-17",
|
||||
}), []);
|
||||
// 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다.
|
||||
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
|
||||
// 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다.
|
||||
const today = formatLocalDate(new Date());
|
||||
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
|
||||
const [draftResultFilters, setDraftResultFilters] =
|
||||
useState<ResultFilters>(initialFilters);
|
||||
@@ -429,15 +431,17 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
|
||||
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
|
||||
const handleApplyResultFilters = () => {
|
||||
if (!draftResultFilters.startDate || !draftResultFilters.endDate) {
|
||||
setErrorMessage("조회 시작일과 종료일을 입력해야 합니다.");
|
||||
if (resultMode === "history") {
|
||||
const dateError = validateWaterRiskDateRange(
|
||||
draftResultFilters.startDate, draftResultFilters.endDate, today,
|
||||
);
|
||||
if (dateError) {
|
||||
setErrorMessage(dateError);
|
||||
return;
|
||||
}
|
||||
if (draftResultFilters.startDate > draftResultFilters.endDate) {
|
||||
setErrorMessage("조회 시작일은 종료일보다 늦을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
setResultCurrentPage(1);
|
||||
setAppliedResultFilters({ ...draftResultFilters });
|
||||
setReloadVersion((version) => version + 1);
|
||||
@@ -524,6 +528,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
<input
|
||||
type="date"
|
||||
value={draftResultFilters.startDate}
|
||||
min={getWaterRiskMinStartDate(draftResultFilters.endDate)}
|
||||
max={draftResultFilters.endDate || today}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
@@ -537,9 +543,11 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
<input
|
||||
type="date"
|
||||
value={draftResultFilters.endDate}
|
||||
max={today}
|
||||
onChange={(event) =>
|
||||
setDraftResultFilters((current) => ({
|
||||
...current,
|
||||
startDate: adjustWaterRiskStartDate(current.startDate, event.target.value),
|
||||
endDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
@@ -674,6 +682,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
setAppliedResultFilters(defaultFilters);
|
||||
setResultCurrentPage(1);
|
||||
setSelectedResultKey("");
|
||||
setErrorMessage("");
|
||||
setReloadVersion((version) => version + 1);
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
@@ -681,6 +691,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
{errorMessage ? (
|
||||
<div role="alert" className="shrink-0 px-1 text-xl text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={tableRows}
|
||||
headlist={WATER_RISK_RESULT_HEADLIST}
|
||||
@@ -715,11 +731,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<div aria-live="polite" className="px-1 text-xl text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 캘린더와 조회 검증에서 함께 사용한다. */
|
||||
export const WATER_RISK_MAX_SEARCH_DAYS = 366;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 날짜 입력을 UTC 일자 값으로 읽어 시간대와 일광절약시간 영향을 제거한다. */
|
||||
const parseDate = (value: string): number | null => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const timestamp = Date.parse(`${value}T00:00:00Z`);
|
||||
return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value
|
||||
? timestamp : null;
|
||||
};
|
||||
|
||||
/** 선택 종료일을 포함해 서버가 허용하는 가장 이른 시작일이다. */
|
||||
export const getWaterRiskMinStartDate = (endDate: string): string | undefined => {
|
||||
const end = parseDate(endDate);
|
||||
return end === null ? undefined
|
||||
: new Date(end - (WATER_RISK_MAX_SEARCH_DAYS - 1) * DAY_MS).toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
/** 달력 제한을 우회한 직접 입력도 API 요청 전에 검증한다. */
|
||||
export const validateWaterRiskDateRange = (startDate: string, endDate: string, today: string): string => {
|
||||
if (!startDate || !endDate) return "조회 시작일과 종료일을 입력해야 합니다.";
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
if (start === null || end === null) return "올바른 조회 날짜를 입력해 주세요.";
|
||||
if (endDate > today) return "조회 종료일은 오늘보다 늦을 수 없습니다.";
|
||||
if (start > end) return "조회 시작일은 종료일보다 늦을 수 없습니다.";
|
||||
if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) {
|
||||
return "강우 발생기간은 종료일 기준 최대 1년(366일)까지 조회할 수 있습니다.";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
/** 종료일 변경으로 기존 시작일이 허용 범위를 벗어난 경우 최근 1년 시작일로 맞춘다. */
|
||||
export const adjustWaterRiskStartDate = (startDate: string, endDate: string): string => {
|
||||
const min = getWaterRiskMinStartDate(endDate);
|
||||
if (!min) return startDate;
|
||||
return !startDate || startDate < min || startDate > endDate ? min : startDate;
|
||||
};
|
||||
@@ -129,18 +129,20 @@ import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel
|
||||
import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayerSettingPanel";
|
||||
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
|
||||
import LayerSettingCard from "./components/layerSetting/LayerSettingCard";
|
||||
<<<<<<< Updated upstream
|
||||
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
|
||||
import { useFloodPredictionMap } from "@/features/fldngPredcPrst/lib/use-flood-prediction-map";
|
||||
=======
|
||||
import { useExmnResultMap } from "./components/exmn/useExmnResultMap";
|
||||
import { getFloodRiskGradeClassName } from "@/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent";
|
||||
>>>>>>> Stashed changes
|
||||
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,
|
||||
@@ -224,7 +226,6 @@ 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] = [
|
||||
@@ -1185,16 +1186,9 @@ const Map = () => {
|
||||
const fldngHstryYearFilterSignatureRef = useRef("");
|
||||
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 [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>({ ...EMPTY_EXMN_MAP_FILTERS });
|
||||
const [exmnMnhlFilters, setExmnMnhlFilters] = useState<ExmnMapFilters>({ ...EMPTY_EXMN_MAP_FILTERS });
|
||||
const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] =
|
||||
useState<SwagePrcsFcltLayerMode>("all");
|
||||
const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState<
|
||||
@@ -1243,26 +1237,6 @@ const Map = () => {
|
||||
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으로 묶어 기존 지도 로직과 충돌을 줄인다.
|
||||
@@ -1316,15 +1290,6 @@ 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 스타일에 사용한다.
|
||||
@@ -1691,39 +1656,6 @@ 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) => {
|
||||
@@ -2991,141 +2923,14 @@ 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,
|
||||
// 기본 전체 조사 레이어와 상세현황 위치 이동의 수명을 전용 hook에서 관리한다.
|
||||
const { removeLayer: removeExmnResultLayer, messages: exmnLayerMessages } = useExmnResultMap({
|
||||
gisApp: GisApp, mapRef: gisMaps, ready: mapReady, treeData, entry: exmnMapEntryContext,
|
||||
pipeChecked: isExmnPplChecked, manholeChecked: isExmnMnhlChecked,
|
||||
pipeEnabled: isExmnPplSettingEnabled, manholeEnabled: isExmnMnhlSettingEnabled,
|
||||
pipeFilters: exmnPplFilters, manholeFilters: exmnMnhlFilters,
|
||||
pipeStyle: createExmnPipeStyle, manholeStyle: createExmnManholeStyle,
|
||||
});
|
||||
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;
|
||||
@@ -4630,6 +4435,7 @@ const Map = () => {
|
||||
content: (
|
||||
<ExmnLayerSettingPanel
|
||||
jobType="PPL"
|
||||
statusMessage={exmnLayerMessages.PPL}
|
||||
filters={exmnPplFilters}
|
||||
onFiltersChange={setExmnPplFilters}
|
||||
enabled={isExmnPplSettingEnabled}
|
||||
@@ -4648,6 +4454,7 @@ const Map = () => {
|
||||
content: (
|
||||
<ExmnLayerSettingPanel
|
||||
jobType="MNHL"
|
||||
statusMessage={exmnLayerMessages.MNHL}
|
||||
filters={exmnMnhlFilters}
|
||||
onFiltersChange={setExmnMnhlFilters}
|
||||
enabled={isExmnMnhlSettingEnabled}
|
||||
@@ -4760,6 +4567,7 @@ const Map = () => {
|
||||
applySinkholeRadius,
|
||||
applySinkholeYearRange,
|
||||
draftAgedPipeFilters,
|
||||
exmnLayerMessages,
|
||||
exmnMnhlFilters,
|
||||
exmnPplFilters,
|
||||
handleApplyAgedPipeFilters,
|
||||
@@ -7103,42 +6911,6 @@ const Map = () => {
|
||||
}
|
||||
}, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !isExmnPplChecked) {
|
||||
removeExmnResultLayer("PPL");
|
||||
return;
|
||||
}
|
||||
void loadExmnResultLayer(
|
||||
"PPL",
|
||||
isExmnPplSettingEnabled ? exmnPplFilters : EMPTY_EXMN_MAP_FILTERS,
|
||||
);
|
||||
}, [
|
||||
exmnPplFilters,
|
||||
isExmnPplChecked,
|
||||
isExmnPplSettingEnabled,
|
||||
loadExmnResultLayer,
|
||||
mapReady,
|
||||
removeExmnResultLayer,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !isExmnMnhlChecked) {
|
||||
removeExmnResultLayer("MNHL");
|
||||
return;
|
||||
}
|
||||
void loadExmnResultLayer(
|
||||
"MNHL",
|
||||
isExmnMnhlSettingEnabled ? exmnMnhlFilters : EMPTY_EXMN_MAP_FILTERS,
|
||||
);
|
||||
}, [
|
||||
exmnMnhlFilters,
|
||||
isExmnMnhlChecked,
|
||||
isExmnMnhlSettingEnabled,
|
||||
loadExmnResultLayer,
|
||||
mapReady,
|
||||
removeExmnResultLayer,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !isSinkholeChecked) {
|
||||
removeSinkholeLayer();
|
||||
|
||||
@@ -639,3 +639,33 @@
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 조사 종합집계 필터, 조사 상세표·Excel, 본단위등급표와 시설 상세의 코드명 수동 검증이 남아 있다.
|
||||
|
||||
## 2026-10-01 조사 레이어 검색 독립 표출 검증
|
||||
- BE compileJava --offline 통과. 임시 ExmnMapLayerScopeTest 10건 통과(실패 0/오류 0): 다지역 동일 번호 분리, 지역 미확정, 중복 도형 배제, 번호/도형 누락 분류, 빈 결과, 선택 사업/분구 전달·연결 검증, 모순 지역 조건, 상류맨홀만 반환, 501건 배치 분할. 처음 2건의 Mockito 중첩 stubbing 오류는 테스트 fixture를 수정한 후 모두 통과했다.
|
||||
- FE tsc --noEmit --incremental false --pretty false 통과. Git 공백 검사 통과.
|
||||
- 실제 8883 API: 지역 없는 PPL 40/40/누락0, MNHL 40/18/누락22(도형 없음22), 제보 테스트 사업·덕풍 PPL 3/3/누락0, 동일 MNHL 3/0/도형없음3. 순서는 matched/returned/missing이다.
|
||||
- 등급6과 사업 없는 처리분구 요청은 HTTP400 확인. 정상 조회는 HTTP200 확인.
|
||||
- 실제 3002 Chrome: 검색 없는 /map에서 조사결과 체크 후 관로40·맨홀18 안내 확인, 해제 후 설정 카드 제거 확인. 상세현황 URL로 덕풍 관로 강조와 줌18 이동 육안 확인, 관로 체크 해제 시 강조선 제거 및 재체크 후 40개 조회 복구 확인.
|
||||
- 현재 전체 PPL 단일 측정: UTF-8 약36,262 bytes, 약856ms. 전국 대량 자료 부하검증을 의미하지 않는다. bbox는 계획대로 후속 성능 개선 단계다.
|
||||
- 맨홀 미연결은 상류번호 누락이 아니라 해당 지역 GIS에서 도형을 찾지 못한 결과다. 덕풍3건 및 전체22건 원천 정합성 보정은 별도 확인이 필요하며 자동으로 다른 위치에 표시하지 않는다.
|
||||
- 필터 변경/취소의 모든 경쟁조건, 보고서 PDF, 전국 대량 렌더링 전체 회귀는 미실행이다. 기존 테스트 자산은 수정하지 않았고 이번 임시 검증파일은 결과 요약 후 정리한다.
|
||||
|
||||
### 2026-10-01 후속 사용자 확인
|
||||
- 사용자 확인으로 조사 레이어 ON/OFF 정상 표출을 확인했다.
|
||||
- 사용자 요청에 따라 별도 파란 선택 강조를 제거하고 기본 조사 색상 및 선택 영역 위치 이동을 유지했다. 앞선 강조선 검증은 제거 전 기록이다.
|
||||
|
||||
## 2026-10-01 조사결과 업로드 양식 미준비 안내
|
||||
- 사용자 요청은 양식 생성 절차를 생략하는 수정이 아니라 미생성 상태에 ‘양식 받기’ 선행 안내를 제공하는 것이다.
|
||||
- 실제 다운로드 이력 대신 활성 처리분구의 pplExmnSheetNm/mnhlExmnSheetNm 존재 여부를 기존 사업 상세 API로 확인한다.
|
||||
- 팝업 진입, 데이터 검토 전, 저장 확인·첨부 업로드 전에 확인한다. 검토 후 사업 수정으로 시트 연결이 초기화됐으면 이전 검토 상태를 폐기하고 안내한다.
|
||||
- 문구: “조사 입력양식이 준비되지 않았습니다. 사업정보에서 ‘양식 받기’를 먼저 실행한 후 조사결과를 등록해 주세요. 사업정보를 수정한 경우에도 양식을 다시 받아야 합니다.”
|
||||
- FE ExmnResultRegisterPopupPage.tsx와 exmn-business-validation.ts에 안내/사전 확인을 추가하고 BE ExmnSurveyResultParser의 SHEET_NAME_NOT_ASSIGNED 메시지도 같은 행동 안내로 보완했다. 자동 양식 생성·DB 변경·기존 저장 필수조건 변경은 하지 않았다.
|
||||
- 검증: 미생성/부분생성/공백/정상/처리분구 없음 5건 통과, FE 타입 검사와 BE compileJava --offline 및 diff --check 통과. 임시 파일 생성 없음. 실제 파일 업로드/저장 및 브라우저 회귀는 미실행이다.
|
||||
- 롤백은 이번 안내·사전 확인과 parser 오류문구만 복원한다. 실제 다운로드 성공 여부를 추적하는 기능은 아니다.
|
||||
|
||||
### 2026-10-01 후속 지시: 저장 시에만 조사양식 안내
|
||||
- 앞선 초기 진입/데이터 검토 단계의 양식 준비 안내와 프론트 사전 차단을 제거했다. 저장을 누른 경우에만 공통 alert로 “조사관리에서 해당 사업 행 선택 → 수정 → 조사양식 받기 후 결과등록 재진행”을 안내한다. 검토 결과는 보존한다.
|
||||
- 검토 API는 양식 생성과 같은 명명 규칙으로 미등록 시트명만 메모리에서 보완한다. 저장된 시트명은 우선 사용하고 검토 중 시트명/DB를 저장하지 않는다. 실제 파일의 시트·필수값·중복·PDF 검증은 유지한다.
|
||||
- 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다.
|
||||
- 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다.
|
||||
- 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다.
|
||||
|
||||
@@ -230,3 +230,10 @@
|
||||
- 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음.
|
||||
- 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다.
|
||||
- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다.
|
||||
|
||||
## 2026-10-01 강우 발생기간 조회 제한
|
||||
- 메모리 내 날짜 유틸 검증 12건 통과: 제보 587일 차단, 366일 허용, 367일 차단, 윤년 경계, 잘못된 날짜, 빈값, 역전, 미래 날짜, 과거 종료일 보정, 기간 증가 보정, 정상 시작일 유지, 오늘 기준 기본기간.
|
||||
- git diff --check 통과. 테스트 파일·원시 결과 파일은 생성하지 않았다.
|
||||
- 3002 포트에서 실행 중인 개발 서버가 확인되지 않아 브라우저 달력·배치 및 실API 회귀는 미실행. 서버를 새로 실행하거나 전체 빌드하지 않았다.
|
||||
- 후속 브라우저 확인: 최초/초기화 최근 1년, 종료일 이동 시 시작일 보정, 기간 초과 직접 입력 조회 차단, 검색조건 아래 오류 표시, 정상 조회 및 페이지 이동.
|
||||
- TypeScript 전체 검사 통과: tsc --noEmit --incremental false --pretty false (exit 0).
|
||||
|
||||
@@ -960,3 +960,38 @@
|
||||
- 조사 코드값, 상세표 열 순서, API 파라미터와 백엔드 Excel 파서·분류 목록은 변경하지 않았다.
|
||||
- CMS 조회 실패 또는 미등록 코드에는 기존 화면 라벨과 서버 제공값을 fallback으로 유지한다.
|
||||
- 백엔드 API와 DB는 변경하지 않았다.
|
||||
|
||||
## 2026-10-01 조사 레이어 검색 독립 표출 구현 결과
|
||||
- 일반 /map에서 상단 검색/URL 지역 없이 PPL·MNHL 체크만으로 전체 활성 조사결과를 조회한다. 기존 레이어 색상·사업차수·구조/운영 AND 필터, 설정 토글 OFF의 추가 필터 해제 계약을 유지한다.
|
||||
- ExmnMapController의 sggCd를 선택값으로 변경하고 exmnBizId/trtdstId 선택 조건을 추가했다. 처리분구 단독 요청·잘못된 사업 연결·범위 밖 등급은 400으로 처리한다.
|
||||
- ExmnMapLayerService는 기존 AdministrativeDistrictRepository의 시도+시군구 기준정보로 자료별 지역을 해석하고 지역별 500건 배치를 사용한다. 같은 번호의 다른 지역은 분리하고 동일 지역의 다중 도형은 임의 선택하지 않는다.
|
||||
- missingGeometryCount와 diagnostics(regionUnresolvedCount/identifierMissingCount/geometryNotFoundCount/ambiguousGeometryCount)를 반환한다. MNHL은 기존 상류맨홀만 사용하며 GIS 원천을 임의 보정하지 않는다.
|
||||
- FE 전용 useExmnResultMap hook으로 기본 레이어·선택 강조·유형별 요청 취소/순번 무효화·0건/실패/누락 안내를 분리했다. 지도보기는 URL의 사업/분구를 별도 조회해 전체 extent로 최초 1회 이동한다. 일반 레이어를 선택 사업으로 제한하거나 첫 피처로 대신 이동하지 않는다.
|
||||
- 수정 파일: BE ExmnMapController.java, ExmnFacilityRepository.java, ExmnMapLayerService.java; FE map/page.tsx, exmnMapApi.ts, exmnMapTypes.ts, ExmnLayerSettingPanel.tsx, 신규 useExmnResultMap.ts.
|
||||
- DB DDL/DML·상단 공통 검색바·권한 설정 변경 없음. 사용자 서버를 재시작/종료하지 않았고, 신규 서버/전체 FE build는 실행하지 않았다.
|
||||
- 롤백은 FE를 기존 지역 포함 호출로 복원한 뒤 BE 변경을 복원한다. 순차 배포는 BE를 먼저 반영한다.
|
||||
|
||||
### 2026-10-01 사용자 확인 및 지도보기 강조 제거
|
||||
- 사용자 확인: 조사 레이어 ON/OFF에 따라 시설이 정상 표시됨.
|
||||
- 후속 지시로 지도보기의 별도 파란 강조 레이어를 제거했다. 선택 사업·처리분구 조회와 전체 extent 위치 이동은 유지하고 기존 조사 레이어의 색상/스타일로 표시한다.
|
||||
- 앞선 설계·구현 기록의 별도 선택 강조 생성 내용은 이 결정으로 대체한다. 백엔드·DB 추가 변경 없음.
|
||||
|
||||
## 2026-10-01 조사결과 업로드 양식 미준비 안내
|
||||
- 사용자 요청은 양식 생성 절차를 생략하는 수정이 아니라 미생성 상태에 ‘양식 받기’ 선행 안내를 제공하는 것이다.
|
||||
- 실제 다운로드 이력 대신 활성 처리분구의 pplExmnSheetNm/mnhlExmnSheetNm 존재 여부를 기존 사업 상세 API로 확인한다.
|
||||
- 팝업 진입, 데이터 검토 전, 저장 확인·첨부 업로드 전에 확인한다. 검토 후 사업 수정으로 시트 연결이 초기화됐으면 이전 검토 상태를 폐기하고 안내한다.
|
||||
- 문구: “조사 입력양식이 준비되지 않았습니다. 사업정보에서 ‘양식 받기’를 먼저 실행한 후 조사결과를 등록해 주세요. 사업정보를 수정한 경우에도 양식을 다시 받아야 합니다.”
|
||||
- FE ExmnResultRegisterPopupPage.tsx와 exmn-business-validation.ts에 안내/사전 확인을 추가하고 BE ExmnSurveyResultParser의 SHEET_NAME_NOT_ASSIGNED 메시지도 같은 행동 안내로 보완했다. 자동 양식 생성·DB 변경·기존 저장 필수조건 변경은 하지 않았다.
|
||||
- 검증: 미생성/부분생성/공백/정상/처리분구 없음 5건 통과, FE 타입 검사와 BE compileJava --offline 및 diff --check 통과. 임시 파일 생성 없음. 실제 파일 업로드/저장 및 브라우저 회귀는 미실행이다.
|
||||
- 롤백은 이번 안내·사전 확인과 parser 오류문구만 복원한다. 실제 다운로드 성공 여부를 추적하는 기능은 아니다.
|
||||
|
||||
### 2026-10-01 후속 지시: 저장 시에만 조사양식 안내
|
||||
- 앞선 초기 진입/데이터 검토 단계의 양식 준비 안내와 프론트 사전 차단을 제거했다. 저장을 누른 경우에만 공통 alert로 “조사관리에서 해당 사업 행 선택 → 수정 → 조사양식 받기 후 결과등록 재진행”을 안내한다. 검토 결과는 보존한다.
|
||||
- 검토 API는 양식 생성과 같은 명명 규칙으로 미등록 시트명만 메모리에서 보완한다. 저장된 시트명은 우선 사용하고 검토 중 시트명/DB를 저장하지 않는다. 실제 파일의 시트·필수값·중복·PDF 검증은 유지한다.
|
||||
- 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다.
|
||||
- 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다.
|
||||
- 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다.
|
||||
|
||||
### 2026-10-01 조사양식 안내창 폭 조정
|
||||
- 해당 안내만 56rem 폭과 화면 여백 최대폭을 적용한 모달로 표시한다. 단어 유지와 균형 줄바꿈으로 두 줄 표기를 유도하며 문구와 저장 시점은 유지한다. 다른 공통 알림창은 변경하지 않는다.
|
||||
- git diff --check 통과. 좁은 화면에서는 잘림 방지를 위해 추가 줄바꿈을 허용한다.
|
||||
|
||||
@@ -275,3 +275,9 @@
|
||||
- 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음.
|
||||
- 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다.
|
||||
- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다.
|
||||
|
||||
## 2026-10-01 강우 발생기간 조회 제한
|
||||
- WaterRiskPopupPage.tsx: 고정 초기기간을 최근 1년으로 변경, 시작일 min/max 및 종료일 max 적용, 종료일 기준 시작일 보정, 조회 전 유효성 검사, 초기화 시 오류 제거·재조회, 오류 메시지를 목록 위로 이동했다.
|
||||
- waterRiskDateRange.ts: UTC 날짜 계산으로 포함 366일 경계·윤년을 처리하고 잘못된 날짜/미래/역전/기간 초과를 검증한다.
|
||||
- 참고 화면: 침수이력의 min/max 입력과 준설의 최근 1년 기본기간. 공통 컴포넌트·BE·DB는 변경하지 않았다.
|
||||
- 롤백: 위 두 소스와 이번 문서 절만 복원한다. DB 작업 없음.
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import type {
|
||||
ExmnBusinessSaveRequest,
|
||||
ExmnBusinessDetail,
|
||||
ExmnFileType,
|
||||
} from "../model/exmn-business-types";
|
||||
|
||||
/** 다운로드 이력이 아니라 실제 처리분구별 시트 연결 여부로 양식 준비 상태를 확인한다. */
|
||||
export const getExmnTemplatePrerequisiteMessage = (
|
||||
detail: Pick<ExmnBusinessDetail, "districts">,
|
||||
): string | null => {
|
||||
if (!detail.districts?.length) {
|
||||
return "조사관리에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
|
||||
}
|
||||
if (detail.districts.some(district =>
|
||||
!district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(),
|
||||
)) {
|
||||
return "조사관리에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 브라우저 1차 검사에서 허용하는 조사파일 최대 크기(200MB)다. */
|
||||
export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
StandardFormSection,
|
||||
StandardPopupLayout,
|
||||
} from "@common/business_components";
|
||||
import { Button } from "@common/components";
|
||||
import { Button, Dialog, DialogContent, DialogFooter, DialogClose } from "@common/components";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import type { HttpResponse } from "tus-js-client";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../api/exmn-business-api";
|
||||
import {
|
||||
EXMN_FILE_RULES,
|
||||
getExmnTemplatePrerequisiteMessage,
|
||||
validateExmnFile,
|
||||
validateExmnFiles,
|
||||
} from "../lib/exmn-business-validation";
|
||||
@@ -161,6 +162,8 @@ export function ExmnResultRegisterPopupPage({
|
||||
businessSummary,
|
||||
}: ExmnResultRegisterPopupPageProps) {
|
||||
const { confirm } = useConfirm();
|
||||
// 양식 준비 안내만 넓은 모달로 표시해 다른 공통 알림창의 크기는 유지한다.
|
||||
const [templateNotice, setTemplateNotice] = useState("");
|
||||
const [detail, setDetail] = useState<ExmnBusinessDetail | null>(null);
|
||||
const [files, setFiles] = useState<SelectedFiles>({});
|
||||
/** 파일별 서버 검토 결과를 보관해 다른 파일 검토 후에도 결과와 건수를 유지한다. */
|
||||
@@ -224,6 +227,19 @@ export function ExmnResultRegisterPopupPage({
|
||||
};
|
||||
}, [exmnBizId]);
|
||||
|
||||
/** 검토 후 사업이 수정된 경우도 업로드 전에 발견하도록 서버의 최신 양식 연결 상태를 확인한다. */
|
||||
const ensureTemplateReady = async () => {
|
||||
const latestDetail = await getExmnBusiness(exmnBizId);
|
||||
setDetail(latestDetail);
|
||||
const message = getExmnTemplatePrerequisiteMessage(latestDetail);
|
||||
if (message) {
|
||||
setIsLoading(false);
|
||||
setTemplateNotice(message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */
|
||||
const handleFileChange = (
|
||||
fileType: ExmnFileType,
|
||||
@@ -363,10 +379,14 @@ export function ExmnResultRegisterPopupPage({
|
||||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
)
|
||||
return;
|
||||
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (!(await ensureTemplateReady())) return;
|
||||
// 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다.
|
||||
setIsLoading(false);
|
||||
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
|
||||
setIsLoading(true);
|
||||
await uploadAndLinkAttachments();
|
||||
await commitExmnImport(
|
||||
exmnBizId,
|
||||
@@ -379,13 +399,15 @@ export function ExmnResultRegisterPopupPage({
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "조사결과 최종 저장 중 오류가 발생했습니다.",
|
||||
});
|
||||
const message = error instanceof Error
|
||||
? error.message : "조사결과 최종 저장 중 오류가 발생했습니다.";
|
||||
// 사전 확인 이후 다른 창에서 사업이 수정돼 서버가 차단한 경우도 동일한 알림으로 안내한다.
|
||||
if (message.includes("조사양식 받기")) {
|
||||
setIsLoading(false);
|
||||
setTemplateNotice(message);
|
||||
} else {
|
||||
setNotice({ type: "error", text: message });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -429,6 +451,18 @@ export function ExmnResultRegisterPopupPage({
|
||||
bodyClassName="space-y-3 overflow-x-hidden"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<Dialog open={!!templateNotice} onOpenChange={() => setTemplateNotice("")}>
|
||||
<DialogContent aria-describedby="exmn-template-notice" className="!w-[46rem] !max-w-[calc(100vw-3.2rem)]">
|
||||
<div className="py-10">
|
||||
<p id="exmn-template-notice" className="whitespace-pre-line break-keep text-center text-[1.4rem] leading-relaxed text-foreground [text-wrap:balance]">
|
||||
{templateNotice}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild><Button>확인</Button></DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{notice && notice.type !== "success" && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
Reference in New Issue
Block a user