조사 레이어 표출 및 조사결과 업로드 안내 개선

This commit is contained in:
kiminchan
2026-10-01 15:55:29 +09:00
parent ae341d66ba
commit 83f7676db4
13 changed files with 375 additions and 280 deletions
@@ -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;
};
+18 -246
View File
@@ -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"