통수능/준설관리

This commit is contained in:
2026-08-05 16:05:07 +09:00
parent 0f8b6cd6d0
commit 493acc9ebd
48 changed files with 2698 additions and 2123 deletions
+22 -1
View File
@@ -101,7 +101,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
## 작업 원칙 ## 작업 원칙
1. 모든 변경은 2차 요구사항, 화면설계, 기능 명세 또는 승인된 작업 문서를 근거로 한다. 1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다. 2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다.
3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다. 3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다.
4. 다른 앱 전용 로직을 `business_components`에 넣지 않는다. 4. 다른 앱 전용 로직을 `business_components`에 넣지 않는다.
@@ -117,6 +117,18 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
14. `BaseLayout`, `NoFooterLayout`, `PageLayout`, `SearchDiv`, `SearchFormLeft/Right`, `ListDiv`, `UiTable`, `UiModal`, `Gis` 조합을 우선 재사용한다. 14. `BaseLayout`, `NoFooterLayout`, `PageLayout`, `SearchDiv`, `SearchFormLeft/Right`, `ListDiv`, `UiTable`, `UiModal`, `Gis` 조합을 우선 재사용한다.
15. 새 UI 구조가 꼭 필요하면 기존 패턴으로 해결되지 않는 이유를 먼저 설명한다. 15. 새 UI 구조가 꼭 필요하면 기존 패턴으로 해결되지 않는 이유를 먼저 설명한다.
## 원본 요구사항 사전 확인
- 모든 `sn` 화면 설계, 구현, 수정, 검증과 문서 작업은 시작 전에 아래 원본 파일에서 관련 범위를 직접 확인한다.
- `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx`
- `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx`
- 화면설계서의 관련 슬라이드, 화면 ID, 입력항목, 버튼, 표 컬럼과 사용자 흐름을 확인한다.
- 요구사항정의서의 관련 시트, 요구사항 ID, 기능 목적과 완료 조건을 함께 대조한다.
- `de`나 메인 에이전트의 요약, 기존 작업계획 또는 현재 화면만으로 원본 확인을 대체하지 않는다.
- 확인한 요구사항 ID, 화면 ID와 슬라이드를 작업계획 또는 결과 보고에 남긴다.
- 원본에 없는 화면 동작이나 데이터 항목은 임의로 추가하지 않고 `설계 제안` 또는 `확인 필요`로 구분한다.
- 상세 절차는 `COMMON_DOCS_ROOT/기준문서/012-원본요구사항사전확인.md`를 따른다.
--- ---
## 허용 작업 ## 허용 작업
@@ -181,6 +193,15 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다. VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다.
개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다. 개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다.
### 프론트엔드 실행 프로세스 관리
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
- 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다.
- 기동 시 PID와 포트를 기록하고, 종료 시에는 에이전트가 기동한 정확한 PID와 하위 프로세스 트리만 종료한다.
- 사용자가 계속 실행해 두라고 명시한 경우에는 종료하지 않고 최종 보고에 PID, 포트와 종료 방법을 남긴다.
- 최종 보고 전에 에이전트가 사용한 프론트엔드 포트 해제와 임시 로그 파일 핸들 종료를 확인한다.
이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다. 이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다.
```sh ```sh
@@ -5,9 +5,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components"; import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import { UiTable } from "@common/business_components/ui/table";
import type { UiTableSort } from "@common/business_components/ui"; import type { UiTableSort } from "@common/business_components/ui/table";
import { getRawJson } from "@/components/lib/api"; import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore"; import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
@@ -75,6 +75,8 @@ const FACILITY_TYPE_CODE = "PPL";
const LAYER_ID = "tb_ms_swage_ppl"; const LAYER_ID = "tb_ms_swage_ppl";
const GIS_UPLOAD_PAGE_SIZE = 10; const GIS_UPLOAD_PAGE_SIZE = 10;
const SELECT_MNG_INST_VALUE = ""; const SELECT_MNG_INST_VALUE = "";
// A current/history download is an idempotent GET, so one retry is safe after a transient connection drop.
const DOWNLOAD_NETWORK_RETRY_DELAY_MS = 500;
const DEFAULT_FACILITY: UploadFacilityType = { const DEFAULT_FACILITY: UploadFacilityType = {
facilityTypeCode: FACILITY_TYPE_CODE, facilityTypeCode: FACILITY_TYPE_CODE,
facilityTypeName: "하수관로", facilityTypeName: "하수관로",
@@ -102,6 +104,26 @@ const buildManagementLabel = (mngInstCd?: string | null, mngInstNm?: string | nu
return mngInstNm || mngInstCd; return mngInstNm || mngInstCd;
}; };
// Chrome reports an interrupted fetch as TypeError without an HTTP response.
const isDownloadNetworkError = (error: unknown) => {
if (!(error instanceof TypeError)) return false;
const message = error.message.toLowerCase();
return message.includes("failed to fetch") || message.includes("networkerror");
};
// Retry only transport failures; HTTP validation and server errors must be shown immediately.
const downloadGeoPackage = async (url: string) => {
try {
return await getBlobDataWithHeaders(url);
} catch (error) {
if (!isDownloadNetworkError(error)) throw error;
console.warn("GIS 다운로드 연결이 중단되어 1회 재시도합니다.", error);
await new Promise((resolve) => window.setTimeout(resolve, DOWNLOAD_NETWORK_RETRY_DELAY_MS));
return getBlobDataWithHeaders(url);
}
};
// Keep the object URL alive until Chrome has accepted the synthetic download click.
const saveBlob = (blob: Blob, fileName: string) => { const saveBlob = (blob: Blob, fileName: string) => {
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
@@ -110,7 +132,7 @@ const saveBlob = (blob: Blob, fileName: string) => {
document.body.appendChild(anchor); document.body.appendChild(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
window.URL.revokeObjectURL(url); window.setTimeout(() => window.URL.revokeObjectURL(url), 1_000);
}; };
const getErrorMessage = (error: unknown, fallback: string) => { const getErrorMessage = (error: unknown, fallback: string) => {
@@ -333,7 +355,7 @@ export default function GisManagementPopupPage() {
setIsLoading(true); setIsLoading(true);
setNotice(""); setNotice("");
try { try {
const response = await getBlobDataWithHeaders( const response = await downloadGeoPackage(
`v1/map/gisUploads/swagePpl/download?${params.toString()}`, `v1/map/gisUploads/swagePpl/download?${params.toString()}`,
); );
const downloadId = response.headers.get("X-Gis-Download-Id") ?? ""; const downloadId = response.headers.get("X-Gis-Download-Id") ?? "";
@@ -378,7 +400,7 @@ export default function GisManagementPopupPage() {
setIsLoading(true); setIsLoading(true);
setNotice(""); setNotice("");
try { try {
const response = (await postMutiPart("v1/map/gisUploads", formData)) as GisUploadResponse; const response = (await postMutiPart("/api/gis-upload", formData)) as GisUploadResponse;
if (response.status === "VALIDATION_FAILED") { if (response.status === "VALIDATION_FAILED") {
setValidationErrors(Array.isArray(response.validationErrors) ? response.validationErrors : []); setValidationErrors(Array.isArray(response.validationErrors) ? response.validationErrors : []);
setViewMode("validationErrors"); setViewMode("validationErrors");
@@ -409,7 +431,7 @@ export default function GisManagementPopupPage() {
setIsLoading(true); setIsLoading(true);
setNotice(""); setNotice("");
try { try {
const response = await getBlobDataWithHeaders( const response = await downloadGeoPackage(
`v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/download`, `v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/download`,
); );
saveBlob(response.blob, response.fileName ?? `${selectedHistory.uploadId}.gpkg`); saveBlob(response.blob, response.fileName ?? `${selectedHistory.uploadId}.gpkg`);
@@ -1,8 +1,13 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; import { UiTable } from "@common/business_components/ui/table";
import type {
UiHeadProps,
UiTableSort,
} from "@common/business_components/ui/table";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { Button } from "@common/components"; import { Button } from "@common/components";
import { getData } from "@/components/lib/api"; import { getData } from "@/components/lib/api";
@@ -3,8 +3,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components"; import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import { UiTable } from "@common/business_components/ui/table";
import type { UiTableSort } from "@common/business_components/ui"; import type { UiTableSort } from "@common/business_components/ui/table";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api"; import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore"; import useStore from "@/store/useStore";
@@ -2,9 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components"; import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import { UiTable } from "@common/business_components/ui/table";
import type { UiTableSort } from "@common/business_components/ui"; import type { UiTableSort } from "@common/business_components/ui/table";
import UiPageNations from "@common/business_components/ui/table/uiPageNations"; import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api"; import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore"; import useStore from "@/store/useStore";
@@ -1,11 +1,16 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; import { UiTable } from "@common/business_components/ui/table";
import type {
UiHeadProps,
UiTableSort,
} from "@common/business_components/ui/table";
import { Button } from "@common/components"; import { Button } from "@common/components";
import { getData } from "@/components/lib/api"; import { getData } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
@@ -3,9 +3,13 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components"; import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import { UiTable } from "@common/business_components/ui/table";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; import type {
UiHeadProps,
UiTableSort,
} from "@common/business_components/ui/table";
import { getRawJson } from "@/components/lib/api"; import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import useStore from "@/store/useStore"; import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar"; import LoadingOverlay from "@/components/ui/LoadingBar";
import type { SelectDataType } from "@common/business_components"; import type { SelectDataType } from "@common/business_components";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
import { getData } from "@/app/components/lib/api";
import type {
WaterRiskAreaOption,
WaterRiskMapView,
WaterRiskResultPage,
WaterRiskResultRow,
WaterRiskStatus,
} from "./waterRiskTypes";
export type WaterRiskResultQuery = {
startDate: string;
endDate: string;
ctpvCd?: string;
sggCd?: string;
areaId?: string;
riskStatus?: WaterRiskStatus;
page: number;
size: number;
};
type WaterRiskApiResultRow = Omit<WaterRiskResultRow, "sidoName"> & {
ctpvName: string;
};
/** 통수위험 결과 API 응답을 기존 화면 표시 모델로 정규화한다. */
const normalizeResultRow = (row: WaterRiskApiResultRow): WaterRiskResultRow => ({
...row,
sidoName: row.ctpvName,
currentAccumRainQty: row.currentAccumRainQty ?? null,
pipeSummaries: Array.isArray(row.pipeSummaries) ? row.pipeSummaries : [],
});
/** 등록된 중점관리지역 검색조건을 조회한다. */
export const fetchWaterRiskAreas = async (): Promise<WaterRiskAreaOption[]> => {
const response = await getData("v1/waterRisk/areas");
return Array.isArray(response) ? response : [];
};
/** 기간·행정구역·지역·위험상태에 맞는 결과 페이지를 조회한다. */
export const fetchWaterRiskResults = async (
query: WaterRiskResultQuery,
): Promise<WaterRiskResultPage> => {
const params = new URLSearchParams({
startDate: query.startDate,
endDate: query.endDate,
page: String(query.page),
size: String(query.size),
});
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
if (query.sggCd) params.set("sggCd", query.sggCd);
if (query.areaId) params.set("areaId", query.areaId);
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
const response = await getData(`v1/waterRisk/results?${params.toString()}`);
const items = Array.isArray(response?.items)
? response.items.map((row: WaterRiskApiResultRow) => normalizeResultRow(row))
: [];
return {
items,
total: Number(response?.total ?? 0),
page: Number(response?.page ?? query.page),
size: Number(response?.size ?? query.size),
totalPages: Number(response?.totalPages ?? 0),
};
};
/** 결과 행과 연결된 중점관리지역·유출관로 도형을 조회한다. */
export const fetchWaterRiskMapView = async (
row: WaterRiskResultRow,
): Promise<WaterRiskMapView> => {
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) {
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다.");
}
const params = new URLSearchParams({
ctpvCd: row.ctpvCd,
sggCd: row.sggCd,
});
return getData(
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
);
};
@@ -1,52 +1,33 @@
"use client"; "use client";
export type WaterRiskStatus = "DANGER" | "NORMAL"; export type WaterRiskStatus = "DANGER" | "NORMAL";
export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2"; export type WaterRiskCalculationMethod = "METHOD_1" | "METHOD_2" | "RATIONAL_MANNING";
export type WaterRiskTab = "result" | "setting";
export type WaterRiskPipeSummary = { export type WaterRiskPipeSummary = {
pipeId: string; pipeId: string;
runoffFlowQty: number; runoffFlowQty: number;
conveyQty: number; conveyQty: number;
riskStatus?: WaterRiskStatus;
}; };
export type WaterRiskResultRow = { export type WaterRiskResultRow = {
analysisDate?: string;
areaId: string; areaId: string;
areaName: string; areaName: string;
ctpvCd?: string;
sidoName: string; sidoName: string;
sggCd?: string;
sggName: string; sggName: string;
// 화면설계서 v1.12 결과 그리드용 표시값이며 실제 값은 후속 BE API 응답으로 치환한다. // 기존 물리 테이블에 누적강우량이 없으므로 값이 없을 때는 화면에서 '-'로 표시한다.
currentAccumRainQty: number; currentAccumRainQty: number | null;
forecastRainQty: number; forecastRainQty: number | null;
maxRainIntensity: number; maxRainIntensity: number | null;
maxFloodingAt: string; maxFloodingAt: string;
riskStatus: WaterRiskStatus; riskStatus: WaterRiskStatus;
calculationMethod: WaterRiskCalculationMethod; calculationMethod: WaterRiskCalculationMethod;
pipeSummaries: WaterRiskPipeSummary[]; pipeSummaries: WaterRiskPipeSummary[];
}; };
export type WaterRiskSettingRow = {
areaId: string;
areaName: string;
sidoName: string;
sggName: string;
pipeId: string;
pipeDia: string;
pipeLen: string;
pipeMaterial: string;
setSeq: number;
setCompleteYn: "Y" | "N";
};
export type WaterRiskStagedPipe = {
pipeId: string;
pipeDia: string;
pipeLen: string;
pipeMaterial: string;
setSeq: number;
source: "MAP" | "MOCK";
};
export type WaterRiskGridCell = { export type WaterRiskGridCell = {
gridId: string; gridId: string;
rainQty: number; rainQty: number;
@@ -69,20 +50,10 @@ export type WaterRiskMapPipe = {
export type WaterRiskSessionSnapshot = { export type WaterRiskSessionSnapshot = {
selectedAreaId: string | null; selectedAreaId: string | null;
selectedSettingAreaId: string | null;
selectedSettingAreaName: string;
isSettingEditMode: boolean;
stagedPipes: WaterRiskStagedPipe[];
statusMessage: string;
}; };
export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = { export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
selectedAreaId: null, selectedAreaId: null,
selectedSettingAreaId: null,
selectedSettingAreaName: "",
isSettingEditMode: false,
stagedPipes: [],
statusMessage: "",
}; };
const buildRectanglePolygon = ( const buildRectanglePolygon = (
@@ -456,273 +427,6 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
}, },
]; ];
export const WATER_RISK_SETTING_ROWS: WaterRiskSettingRow[] = [
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-10284",
pipeDia: "900 mm",
pipeLen: "186 m",
pipeMaterial: "콘크리트",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-001",
areaName: "A 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-10291",
pipeDia: "700 mm",
pipeLen: "142 m",
pipeMaterial: "흄관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-002",
areaName: "B 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-45103",
pipeDia: "600 mm",
pipeLen: "98 m",
pipeMaterial: "PE",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-60014",
pipeDia: "800 mm",
pipeLen: "154 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-003",
areaName: "C 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-60018",
pipeDia: "700 mm",
pipeLen: "119 m",
pipeMaterial: "흄관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-004",
areaName: "D 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-72010",
pipeDia: "500 mm",
pipeLen: "101 m",
pipeMaterial: "PVC",
setSeq: 1,
setCompleteYn: "N",
},
{
areaId: "AREA-005",
areaName: "E 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-83014",
pipeDia: "650 mm",
pipeLen: "133 m",
pipeMaterial: "PE",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-83018",
pipeDia: "900 mm",
pipeLen: "176 m",
pipeMaterial: "흄관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-006",
areaName: "F 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-83019",
pipeDia: "800 mm",
pipeLen: "167 m",
pipeMaterial: "콘크리트",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-007",
areaName: "G 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91004",
pipeDia: "700 mm",
pipeLen: "111 m",
pipeMaterial: "PVC",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91010",
pipeDia: "1000 mm",
pipeLen: "201 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91012",
pipeDia: "900 mm",
pipeLen: "184 m",
pipeMaterial: "흄관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-008",
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-91014",
pipeDia: "800 mm",
pipeLen: "159 m",
pipeMaterial: "콘크리트",
setSeq: 3,
setCompleteYn: "Y",
},
{
areaId: "AREA-009",
areaName: "I 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-99001",
pipeDia: "600 mm",
pipeLen: "94 m",
pipeMaterial: "PE",
setSeq: 1,
setCompleteYn: "N",
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-99007",
pipeDia: "850 mm",
pipeLen: "143 m",
pipeMaterial: "흄관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-010",
areaName: "J 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-99009",
pipeDia: "750 mm",
pipeLen: "128 m",
pipeMaterial: "강관",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-011",
areaName: "K 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-11001",
pipeDia: "700 mm",
pipeLen: "121 m",
pipeMaterial: "콘크리트",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-012",
areaName: "L 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-12001",
pipeDia: "820 mm",
pipeLen: "147 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "N",
},
{
areaId: "AREA-013",
areaName: "M 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-13001",
pipeDia: "900 mm",
pipeLen: "163 m",
pipeMaterial: "흄관",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-013",
areaName: "M 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-13002",
pipeDia: "800 mm",
pipeLen: "152 m",
pipeMaterial: "콘크리트",
setSeq: 2,
setCompleteYn: "Y",
},
{
areaId: "AREA-014",
areaName: "N 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
pipeId: "PIPE-14001",
pipeDia: "500 mm",
pipeLen: "88 m",
pipeMaterial: "PVC",
setSeq: 1,
setCompleteYn: "Y",
},
{
areaId: "AREA-015",
areaName: "O 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
pipeId: "PIPE-15001",
pipeDia: "980 mm",
pipeLen: "194 m",
pipeMaterial: "강관",
setSeq: 1,
setCompleteYn: "Y",
},
];
export const WATER_RISK_MAP_AREAS: WaterRiskAreaMapData[] = [ export const WATER_RISK_MAP_AREAS: WaterRiskAreaMapData[] = [
{ {
areaId: "AREA-001", areaId: "AREA-001",
@@ -979,8 +683,6 @@ export const WATER_RISK_SGG_OPTIONS_BY_SIDO: Record<string, string[]> = {
부산광역시: ["전체", "해운대구"], 부산광역시: ["전체", "해운대구"],
}; };
export const WATER_RISK_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
export const WATER_RISK_COLOR_LABELS: Record<WaterRiskGridCell["colorLevel"], string> = { export const WATER_RISK_COLOR_LABELS: Record<WaterRiskGridCell["colorLevel"], string> = {
LEVEL_1: "0.0~20.0 미만", LEVEL_1: "0.0~20.0 미만",
LEVEL_2: "20.0~30.0 미만", LEVEL_2: "20.0~30.0 미만",
@@ -992,9 +694,51 @@ export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) =>
status === "DANGER" ? "위험" : "정상"; status === "DANGER" ? "위험" : "정상";
export const buildWaterRiskMethodLabel = (method: WaterRiskCalculationMethod) => export const buildWaterRiskMethodLabel = (method: WaterRiskCalculationMethod) =>
method === "METHOD_1" ? "방법 1" : "방법 2"; method === "RATIONAL_MANNING"
? "합리식·Manning"
: method === "METHOD_1"
? "방법 1"
: "방법 2";
export const buildWaterRiskSettingAreaName = (areaId: string | null) => { export type WaterRiskAreaOption = {
if (!areaId) return ""; areaId: string;
return WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId)?.areaName ?? ""; areaName: string;
ctpvCd: string;
ctpvName: string;
sggCd: string;
sggName: string;
};
export type WaterRiskResultPage = {
items: WaterRiskResultRow[];
total: number;
page: number;
size: number;
totalPages: number;
};
export type WaterRiskGeoJsonGeometry = {
type: string;
coordinates: unknown;
};
export type WaterRiskMapView = {
analysisDate: string;
areaId: string;
areaName: string;
ctpvCd: string;
ctpvName: string;
sggCd: string;
sggName: string;
riskStatus: WaterRiskStatus;
projection: "EPSG:5179" | string;
areaGeometry: WaterRiskGeoJsonGeometry | null;
pipes: Array<{
pipeId: string;
displayLabel: string;
geometry: WaterRiskGeoJsonGeometry | null;
runoffFlowQty: number;
conveyQty: number;
riskStatus: WaterRiskStatus;
}>;
}; };
+83 -260
View File
@@ -128,11 +128,10 @@ import {
EMPTY_WATER_RISK_SESSION_SNAPSHOT, EMPTY_WATER_RISK_SESSION_SNAPSHOT,
WATER_RISK_MAP_AREAS, WATER_RISK_MAP_AREAS,
WATER_RISK_MAP_PIPES_BY_AREA, WATER_RISK_MAP_PIPES_BY_AREA,
WATER_RISK_PIPE_LAYER_TYPE,
WATER_RISK_RESULT_ROWS, WATER_RISK_RESULT_ROWS,
WATER_RISK_SETTING_ROWS, type WaterRiskMapView,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot, type WaterRiskSessionSnapshot,
type WaterRiskStagedPipe,
} from "./components/waterRisk/waterRiskTypes"; } from "./components/waterRisk/waterRiskTypes";
const AnalysisPopupMenu = dynamic( const AnalysisPopupMenu = dynamic(
@@ -763,19 +762,6 @@ const buildWaterRiskInfoAnchor = (areaId: string | null) => {
return [maxX, (minY + maxY) / 2] as [number, number]; return [maxX, (minY + maxY) / 2] as [number, number];
}; };
const buildWaterRiskSeedPipes = (areaId: string) =>
WATER_RISK_SETTING_ROWS
.filter((row) => row.areaId === areaId)
.sort((a, b) => a.setSeq - b.setSeq)
.map<WaterRiskStagedPipe>((row) => ({
pipeId: row.pipeId,
pipeDia: row.pipeDia,
pipeLen: row.pipeLen,
pipeMaterial: row.pipeMaterial,
setSeq: row.setSeq,
source: "MOCK",
}));
const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") => const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") =>
riskStatus === "DANGER" ? "rgba(198, 40, 40, 0.24)" : "rgba(21, 101, 192, 0.2)"; riskStatus === "DANGER" ? "rgba(198, 40, 40, 0.24)" : "rgba(21, 101, 192, 0.2)";
@@ -1242,48 +1228,6 @@ const createSinkholeFocusedPipeStyle = () => [
}), }),
]; ];
const formatWaterRiskPipeField = (value: unknown, suffix: string) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "-";
if (normalized.endsWith(suffix) || suffix === "") return normalized;
return `${normalized} ${suffix}`.trim();
};
const buildWaterRiskPipeFromFeature = (data: any): WaterRiskStagedPipe | null => {
const values = data?.values_ ?? data;
const pipeId = String(
values?.fclt_mng_no ??
values?.fcltMngNo ??
values?.pipeId ??
values?.id ??
data?.fid ??
"",
).trim();
if (!pipeId) return null;
return {
pipeId,
pipeDia: formatWaterRiskPipeField(
values?.pipe_diameter ?? values?.pdmt_vl ?? values?.pdmtVl,
"mm",
),
pipeLen: formatWaterRiskPipeField(
values?.pipe_lt ?? values?.pipeLt ?? values?.extn_lt ?? values?.extnLt,
"m",
),
pipeMaterial: String(
values?.pipe_material ??
values?.pipeMaterial ??
values?.mqlt_cd ??
values?.mqltCd ??
"-",
).trim() || "-",
setSeq: 0,
source: "MAP",
};
};
type VertexHandleMeta = { type VertexHandleMeta = {
kind: "vertex" | "midpoint"; kind: "vertex" | "midpoint";
geometryType: string; geometryType: string;
@@ -2701,6 +2645,11 @@ const Map = () => {
const [waterRiskSession, setWaterRiskSession] = useState<WaterRiskSessionSnapshot>( const [waterRiskSession, setWaterRiskSession] = useState<WaterRiskSessionSnapshot>(
EMPTY_WATER_RISK_SESSION_SNAPSHOT, EMPTY_WATER_RISK_SESSION_SNAPSHOT,
); );
// 팝업에서 실제 API로 조회한 결과 행과 GeoJSON을 지도·정보카드의 단일 원본으로 보관한다.
const [selectedWaterRiskApiResult, setSelectedWaterRiskApiResult] =
useState<WaterRiskResultRow | null>(null);
const [selectedWaterRiskMapView, setSelectedWaterRiskMapView] =
useState<WaterRiskMapView | null>(null);
const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<[number, number] | null>(null); const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<[number, number] | null>(null);
const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState<Record<string, number> | null>(null); const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState<Record<string, number> | null>(null);
const gisEditVisibleSessionChanges = useMemo( const gisEditVisibleSessionChanges = useMemo(
@@ -2876,8 +2825,8 @@ const Map = () => {
}, [waterRiskSessionSnapshot]); }, [waterRiskSessionSnapshot]);
const selectedWaterRiskResultRow = useMemo( const selectedWaterRiskResultRow = useMemo(
() => getWaterRiskResultRow(waterRiskSession.selectedAreaId), () => selectedWaterRiskApiResult ?? getWaterRiskResultRow(waterRiskSession.selectedAreaId),
[waterRiskSession.selectedAreaId], [selectedWaterRiskApiResult, waterRiskSession.selectedAreaId],
); );
const syncWaterRiskInfoCardPosition = useCallback(() => { const syncWaterRiskInfoCardPosition = useCallback(() => {
@@ -3209,7 +3158,10 @@ const Map = () => {
waterRiskPipeSourceRef.current?.clear(); waterRiskPipeSourceRef.current?.clear();
}, []); }, []);
const renderWaterRiskSelection = useCallback((areaId: string | null) => { const renderWaterRiskSelection = useCallback((
areaId: string | null,
mapView: WaterRiskMapView | null,
) => {
const map = gisMaps.current; const map = gisMaps.current;
if (!map) return; if (!map) return;
@@ -3219,9 +3171,62 @@ const Map = () => {
areaSource.clear(); areaSource.clear();
gridSource.clear(); gridSource.clear();
const pipeSource = ensureWaterRiskPipeLayer();
pipeSource?.clear();
if (!areaId) return; if (!areaId) return;
if (mapView && mapView.areaId === areaId) {
const geoJson = new GeoJSON();
const projection = mapView.projection || "EPSG:5179";
let focusGeometry: any | null = null;
if (mapView.areaGeometry) {
const geometry = geoJson.readGeometry(mapView.areaGeometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const areaFeature = new Feature({
geometry,
areaId: mapView.areaId,
areaName: mapView.areaName,
riskStatus: mapView.riskStatus,
});
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
areaSource.addFeature(areaFeature);
focusGeometry = geometry;
}
mapView.pipes.forEach((pipe) => {
if (!pipeSource || !pipe.geometry) return;
const geometry = geoJson.readGeometry(pipe.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const pipeFeature = new Feature({
geometry,
pipeId: pipe.pipeId,
displayLabel: pipe.displayLabel || pipe.pipeId,
});
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
pipeSource.addFeature(pipeFeature);
if (!focusGeometry) focusGeometry = geometry;
});
if (!focusGeometry) return;
const extent = focusGeometry.getExtent();
const [, minY = 0, maxX = 0, maxY = 0] = extent;
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
map.getView().fit(extent, {
duration: 300,
padding: [120, 120, 120, 120],
maxZoom: 15,
constrainResolution: true,
nearest: true,
});
return;
}
const areaData = getWaterRiskAreaMapData(areaId); const areaData = getWaterRiskAreaMapData(areaId);
const resultRow = getWaterRiskResultRow(areaId); const resultRow = getWaterRiskResultRow(areaId);
if (!areaData || !resultRow) return; if (!areaData || !resultRow) return;
@@ -3246,9 +3251,7 @@ const Map = () => {
gridSource.addFeature(cellFeature); gridSource.addFeature(cellFeature);
}); });
const pipeSource = ensureWaterRiskPipeLayer();
const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? []; const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? [];
pipeSource?.clear();
pipes.forEach((pipe) => { pipes.forEach((pipe) => {
if (!pipeSource) return; if (!pipeSource) return;
const pipeFeature = new Feature({ const pipeFeature = new Feature({
@@ -3272,90 +3275,6 @@ const Map = () => {
}); });
}, [ensureWaterRiskAreaLayer, ensureWaterRiskGridLayer, ensureWaterRiskPipeLayer]); }, [ensureWaterRiskAreaLayer, ensureWaterRiskGridLayer, ensureWaterRiskPipeLayer]);
const setWaterRiskStatusMessage = useCallback((statusMessage: string) => {
setWaterRiskSession((current) => ({
...current,
statusMessage,
}));
}, []);
const startWaterRiskSettingEdit = useCallback((areaId: string, areaName: string) => {
setWaterRiskSession((current) => {
const shouldSeedPipes =
current.selectedSettingAreaId !== areaId || current.stagedPipes.length === 0;
return {
...current,
selectedAreaId: areaId,
selectedSettingAreaId: areaId,
selectedSettingAreaName: areaName,
isSettingEditMode: true,
stagedPipes: shouldSeedPipes ? buildWaterRiskSeedPipes(areaId) : current.stagedPipes,
statusMessage: `${areaName} 설정 편집 모드입니다. 지도에서 하수관로를 클릭하세요.`,
};
});
setIsOpen(false);
setFeatureData(null);
}, []);
const stopWaterRiskSettingEdit = useCallback(() => {
setWaterRiskSession((current) => ({
...current,
isSettingEditMode: false,
statusMessage: "",
}));
}, []);
const removeWaterRiskStagedPipe = useCallback((pipeId: string) => {
setWaterRiskSession((current) => ({
...current,
stagedPipes: current.stagedPipes
.filter((pipe) => pipe.pipeId !== pipeId)
.map((pipe, index) => ({
...pipe,
setSeq: index + 1,
})),
statusMessage: `${pipeId} 관로를 staged 목록에서 제거했습니다.`,
}));
}, []);
const reorderWaterRiskStagedPipe = useCallback((pipeId: string, direction: "UP" | "DOWN") => {
setWaterRiskSession((current) => {
const currentIndex = current.stagedPipes.findIndex((pipe) => pipe.pipeId === pipeId);
if (currentIndex < 0) return current;
const nextIndex =
direction === "UP" ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= current.stagedPipes.length) {
return current;
}
const reordered = [...current.stagedPipes];
const [targetPipe] = reordered.splice(currentIndex, 1);
if (!targetPipe) {
return current;
}
reordered.splice(nextIndex, 0, targetPipe);
return {
...current,
stagedPipes: reordered.map((pipe, index) => ({
...pipe,
setSeq: index + 1,
})),
statusMessage: `${pipeId} 관로 순번을 조정했습니다.`,
};
});
}, []);
const clearWaterRiskStagedPipes = useCallback(() => {
setWaterRiskSession((current) => ({
...current,
stagedPipes: [],
statusMessage: "staged 유출관로 목록을 초기화했습니다.",
}));
}, []);
useEffect(() => { useEffect(() => {
if (!mapReady || !gisMaps.current) return; if (!mapReady || !gisMaps.current) return;
if (!waterRiskSession.selectedAreaId) { if (!waterRiskSession.selectedAreaId) {
@@ -3365,17 +3284,19 @@ const Map = () => {
return; return;
} }
renderWaterRiskSelection(waterRiskSession.selectedAreaId); renderWaterRiskSelection(waterRiskSession.selectedAreaId, selectedWaterRiskMapView);
}, [ }, [
clearWaterRiskLayers, clearWaterRiskLayers,
mapReady, mapReady,
renderWaterRiskSelection, renderWaterRiskSelection,
selectedWaterRiskMapView,
waterRiskSession.selectedAreaId, waterRiskSession.selectedAreaId,
]); ]);
useEffect(() => { useEffect(() => {
if (selectedWaterRiskMapView) return;
setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId)); setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId));
}, [waterRiskSession.selectedAreaId]); }, [selectedWaterRiskMapView, waterRiskSession.selectedAreaId]);
useEffect(() => { useEffect(() => {
const popup = waterRiskPopupWindowRef.current; const popup = waterRiskPopupWindowRef.current;
@@ -3414,64 +3335,27 @@ const Map = () => {
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) { if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) {
const areaId = String(data.payload?.areaId ?? "").trim(); const areaId = String(data.payload?.areaId ?? "").trim();
if (!areaId) return; if (!areaId) return;
setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(areaId)); const resultRow = data.payload?.resultRow as WaterRiskResultRow | undefined;
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
setSelectedWaterRiskApiResult(resultRow);
setSelectedWaterRiskMapView(mapView);
setWaterRiskSession((current) => ({ setWaterRiskSession((current) => ({
...current, ...current,
selectedAreaId: areaId, selectedAreaId: areaId,
statusMessage: `${getWaterRiskResultRow(areaId)?.areaName ?? areaId} 지도 강조를 갱신했습니다.`,
})); }));
setIsOpen(false); setIsOpen(false);
setFeatureData(null); setFeatureData(null);
return; return;
} }
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT) {
const areaId = String(data.payload?.areaId ?? "").trim();
const areaName = String(data.payload?.areaName ?? "").trim();
if (!areaId) return;
startWaterRiskSettingEdit(areaId, areaName || getWaterRiskResultRow(areaId)?.areaName || areaId);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT) {
stopWaterRiskSettingEdit();
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE) {
const pipeId = String(data.payload?.pipeId ?? "").trim();
if (pipeId) {
removeWaterRiskStagedPipe(pipeId);
}
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE) {
const pipeId = String(data.payload?.pipeId ?? "").trim();
const direction = data.payload?.direction === "UP" ? "UP" : "DOWN";
if (pipeId) {
reorderWaterRiskStagedPipe(pipeId, direction);
}
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES) {
clearWaterRiskStagedPipes();
}
}; };
window.addEventListener("message", handleWaterRiskPopupMessage); window.addEventListener("message", handleWaterRiskPopupMessage);
return () => { return () => {
window.removeEventListener("message", handleWaterRiskPopupMessage); window.removeEventListener("message", handleWaterRiskPopupMessage);
}; };
}, [ }, [syncWaterRiskPopup]);
clearWaterRiskStagedPipes,
removeWaterRiskStagedPipe,
reorderWaterRiskStagedPipe,
startWaterRiskSettingEdit,
stopWaterRiskSettingEdit,
syncWaterRiskPopup,
]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -3882,59 +3766,6 @@ const Map = () => {
return; return;
} }
if (waterRiskSession.isSettingEditMode) {
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName !== WATER_RISK_PIPE_LAYER_TYPE) {
setWaterRiskStatusMessage("통수위험 설정 모드에서는 하수관로만 선택할 수 있습니다.");
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
const nextPipe = buildWaterRiskPipeFromFeature(data);
if (!nextPipe) {
setWaterRiskStatusMessage("선택한 하수관로 정보를 staged 목록으로 옮기지 못했습니다.");
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
setWaterRiskSession((current) => {
if (current.stagedPipes.some((pipe) => pipe.pipeId === nextPipe.pipeId)) {
return {
...current,
statusMessage: `${nextPipe.pipeId} 관로는 이미 staged 목록에 있습니다.`,
};
}
if (current.stagedPipes.length >= 3) {
return {
...current,
statusMessage: "유출관로는 최대 3건까지만 staged 목록에 담을 수 있습니다.",
};
}
return {
...current,
stagedPipes: [
...current.stagedPipes,
{
...nextPipe,
setSeq: current.stagedPipes.length + 1,
},
],
statusMessage: `${nextPipe.pipeId} 관로를 staged 목록에 추가했습니다.`,
};
});
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
let map = gisMaps.current; let map = gisMaps.current;
if (!map) { if (!map) {
console.warn("지도가 초기화되지 않았습니다."); console.warn("지도가 초기화되지 않았습니다.");
@@ -5496,12 +5327,11 @@ const Map = () => {
if (!popup.closed) return; if (!popup.closed) return;
clearWaterRiskPopupCloseWatcher(); clearWaterRiskPopupCloseWatcher();
waterRiskPopupWindowRef.current = null; waterRiskPopupWindowRef.current = null;
stopWaterRiskSettingEdit();
}, 300); }, 300);
} }
return popup; return popup;
}, [clearWaterRiskPopupCloseWatcher, stopWaterRiskSettingEdit]); }, [clearWaterRiskPopupCloseWatcher]);
const openGisEditSessionPopup = useCallback(() => { const openGisEditSessionPopup = useCallback(() => {
const existingPopup = gisEditSessionPopupWindowRef.current; const existingPopup = gisEditSessionPopupWindowRef.current;
@@ -7244,16 +7074,6 @@ const Map = () => {
{agedPipeDownloadProgressText} {agedPipeDownloadProgressText}
</div> </div>
) : null} ) : null}
{waterRiskSession.isSettingEditMode ? (
<div className="absolute left-4 top-36 z-[60] max-w-[560px] rounded border border-[#f0d27b] bg-white px-4 py-3 text-sm text-[#7a5b00] shadow">
<div className="font-semibold">
통수위험 설정 모드: {waterRiskSession.selectedSettingAreaName || "중점관리지역 선택 전"}
</div>
<div className="mt-1">
지도에서 하수관로를 클릭하면 staged 목록에 추가됩니다. 최대 3건까지 선택할 수 있습니다.
</div>
</div>
) : null}
{selectedWaterRiskResultRow && waterRiskInfoCardStyle ? ( {selectedWaterRiskResultRow && waterRiskInfoCardStyle ? (
<div <div
style={waterRiskInfoCardStyle} style={waterRiskInfoCardStyle}
@@ -7291,7 +7111,9 @@ const Map = () => {
예측강우량(mm/hr) 예측강우량(mm/hr)
</td> </td>
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-[#1f2937]"> <td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-[#1f2937]">
{selectedWaterRiskResultRow.forecastRainQty.toFixed(1)} {selectedWaterRiskResultRow.forecastRainQty == null
? "-"
: selectedWaterRiskResultRow.forecastRainQty.toFixed(1)}
</td> </td>
</tr> </tr>
@@ -7331,7 +7153,7 @@ const Map = () => {
<td className="border-b border-r border-[#e5eaf1] bg-[#fbfdff] px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-[#1f2937]"> <td className="border-b border-r border-[#e5eaf1] bg-[#fbfdff] px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-[#1f2937]">
통수량 통수량
<br /> <br />
(㎥/hr) (㎥/s)
</td> </td>
<td className="border-b border-[#e5eaf1] px-[9px] py-[7px] text-right text-[11px] font-medium text-[#1f2937]"> <td className="border-b border-[#e5eaf1] px-[9px] py-[7px] text-right text-[11px] font-medium text-[#1f2937]">
{pipe.conveyQty.toFixed(1)} {pipe.conveyQty.toFixed(1)}
@@ -7341,7 +7163,7 @@ const Map = () => {
<td className="border-r border-[#e5eaf1] bg-[#fbfdff] px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-[#1f2937]"> <td className="border-r border-[#e5eaf1] bg-[#fbfdff] px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-[#1f2937]">
유출유량 유출유량
<br /> <br />
(㎥/hr) (㎥/s)
</td> </td>
<td className={`px-[9px] py-[7px] text-right text-[11px] ${isDanger ? "font-bold text-[#d32f2f]" : "font-medium text-[#1f2937]"}`}> <td className={`px-[9px] py-[7px] text-right text-[11px] ${isDanger ? "font-bold text-[#d32f2f]" : "font-medium text-[#1f2937]"}`}>
{pipe.runoffFlowQty.toFixed(1)} {pipe.runoffFlowQty.toFixed(1)}
@@ -7433,6 +7255,7 @@ const Map = () => {
layerSettingView={ActiveLayerSettingView} layerSettingView={ActiveLayerSettingView}
treeSectionClassName="rounded-sm border border-border bg-background px-1 py-2 text-foreground" treeSectionClassName="rounded-sm border border-border bg-background px-1 py-2 text-foreground"
detachedTreeToggleButton={true} detachedTreeToggleButton={true}
showLayerPanelTitle={false}
isPlaying={isPlaying} isPlaying={isPlaying}
setIsPlaying={setIsPlaying as any} setIsPlaying={setIsPlaying as any}
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
@@ -9,7 +9,7 @@
* - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리 * - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리
*/ */
import React from 'react'; import React from 'react';
import { SelectBox } from '@common/business_components/ui'; import { SelectBox } from "@common/business_components/ui/select";
import { Label } from '@common/components'; import { Label } from '@common/components';
import SwagePpl from './swagePpl'; import SwagePpl from './swagePpl';
import SwagePrcsFclt from './swagePrcsFclt'; import SwagePrcsFclt from './swagePrcsFclt';
@@ -13,7 +13,7 @@
*/ */
import React from 'react'; import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox } from '@common/business_components/ui'; import { SelectBox } from "@common/business_components/ui/select";
import { Button, Input, Label } from '@common/components'; import { Button, Input, Label } from '@common/components';
import useStore from '@/store/useStore'; import useStore from '@/store/useStore';
import useSearchModalStore from '@/store/useSearchModalStore'; import useSearchModalStore from '@/store/useSearchModalStore';
@@ -16,14 +16,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Label } from '@common/components'; import { Button, Label } from '@common/components';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox, UiTable } from '@common/business_components/ui'; import { SelectBox } from "@common/business_components/ui/select";
import { UiTable } from "@common/business_components/ui/table";
import LoadingOverlay from '@/components/ui/LoadingBar'; import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab'; import useReportTab from './useReportTab';
import useStore from '@/store/useStore'; import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore'; import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore'; import { useReportTabStore } from '@/store/useReportTabStore';
import type { UiTableSort } from '@common/business_components/ui'; import type { UiTableSort } from "@common/business_components/ui/table";
import type { FlattenedFolder, ReportDataFile } from './useReportTab'; import type { FlattenedFolder, ReportDataFile } from './useReportTab';
// 하수도정비 기본계획 보고서 // 하수도정비 기본계획 보고서
@@ -13,7 +13,8 @@
*/ */
'use client'; 'use client';
import LoadingOverlay from '@/components/ui/LoadingBar'; import LoadingOverlay from '@/components/ui/LoadingBar';
import { UITab, UiTable } from '@common/business_components/ui'; import { UITab } from "@common/business_components/ui/tab";
import { UiTable } from "@common/business_components/ui/table";
import { Button } from '@common/components/ui'; import { Button } from '@common/components/ui';
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
@@ -26,7 +27,10 @@ import useSearchModal from './useSearchModal';
import useBasicTab from './useBasicTab'; import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab'; import useOperationTab from './useOperationTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import type { UiHeadProps, UiTableSort } from '@common/business_components/ui'; import type {
UiHeadProps,
UiTableSort,
} from "@common/business_components/ui/table";
type SearchModalProps = { type SearchModalProps = {
renderMode?: 'launcher' | 'popup'; renderMode?: 'launcher' | 'popup';
@@ -15,7 +15,7 @@ import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button, Input, Label } from '@common/components'; import { Button, Input, Label } from '@common/components';
import { MS_SWAGE_MHL_USE_CODES } from '../components/common/table'; import { MS_SWAGE_MHL_USE_CODES } from '../components/common/table';
import { SelectBox } from '@common/business_components/ui'; import { SelectBox } from "@common/business_components/ui/select";
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '../../../store/useSearchModalStore'; import useSearchModalStore from '../../../store/useSearchModalStore';
import useSearchModal from './useSearchModal'; import useSearchModal from './useSearchModal';
@@ -12,7 +12,7 @@
* - Enter 키 입력 시 자동 조회 지원 * - Enter 키 입력 시 자동 조회 지원
*/ */
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox } from '@common/business_components/ui'; import { SelectBox } from "@common/business_components/ui/select";
import { Button, Input, Label } from '@common/components'; import { Button, Input, Label } from '@common/components';
import React from 'react'; import React from 'react';
import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table'; import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
@@ -12,7 +12,7 @@
* 🔗 사용 API * 🔗 사용 API
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service) * - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
*/ */
import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { import {
MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES,
FldngWtlvMapExcelCol, MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol FldngWtlvMapExcelCol, MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol
@@ -34,7 +34,7 @@ import { swagePmstnCqlFilters } from './swagePmstn';
import { stofcbrCqlFilters } from './stofcbr'; import { stofcbrCqlFilters } from './stofcbr';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import type { UiTableSort } from '@common/business_components/ui'; import type { UiTableSort } from "@common/business_components/ui/table";
const isPopupWindow = () => const isPopupWindow = () =>
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -14,7 +14,7 @@
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
* - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service) * - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service)
*/ */
import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui'; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from '../components/common/table'; import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
import { getData } from "@/components/lib/api"; import { getData } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
@@ -25,7 +25,7 @@ import useSearchModalStore from '@/store/useSearchModalStore';
import useStore from '@/store/useStore'; import useStore from '@/store/useStore';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import type { UiTableSort } from '@common/business_components/ui'; import type { UiTableSort } from "@common/business_components/ui/table";
const isPopupWindow = () => const isPopupWindow = () =>
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -21,7 +21,7 @@ import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab'; import useOperationTab from './useOperationTab';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import type { UiTableSort } from '@common/business_components/ui'; import type { UiTableSort } from "@common/business_components/ui/table";
const isPopupWindow = () => const isPopupWindow = () =>
typeof window !== 'undefined' && typeof window !== 'undefined' &&
@@ -19,7 +19,8 @@
*/ */
'use client' 'use client'
import { deleteData, getData } from '@/components/lib/api' import { deleteData, getData } from '@/components/lib/api'
import { UiTable, downloadExcelWithKoreanHeaders } from '@common/business_components/ui' import { UiTable } from '@common/business_components/ui/table'
import { downloadExcelWithKoreanHeaders } from '@/shared/lib/download-excel'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
@@ -325,4 +326,4 @@ const Result = () => {
) )
} }
export default Result export default Result
@@ -19,7 +19,8 @@
*/ */
'use client' 'use client'
import { deleteData, getData } from '@/components/lib/api' import { deleteData, getData } from '@/components/lib/api'
import { UiTable, downloadExcelWithKoreanHeaders } from '@common/business_components/ui' import { UiTable } from '@common/business_components/ui/table'
import { downloadExcelWithKoreanHeaders } from '@/shared/lib/download-excel'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
@@ -317,4 +318,4 @@ const Result = () => {
) )
} }
export default Result export default Result
+5 -3
View File
@@ -13,15 +13,16 @@
*/ */
"use client"; "use client";
import React, { useEffect } from 'react'; import React, { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { BaseLayout } from "@common/components"; import { BaseLayout } from "@common/components/ui/layout/base";
import type { MenuItem } from "@/entities/menu"; import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib"; import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout"; import { PageLayout } from "@/shared/ui/page-layout";
import useStore from '../store/useStore'; import useStore from "../store/useStore";
import { authService } from "@/entities/auth"; import { authService } from "@/entities/auth";
import { GlobalAnalysisCompletionWatcher } from "./ii/useAnalysisCompletionWatcher";
export default function WithBaseLayout({ export default function WithBaseLayout({
children, children,
@@ -55,6 +56,7 @@ export default function WithBaseLayout({
currentPath={pathname ?? ""} currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick} handleMenuClick={handleMenuClick}
> >
<GlobalAnalysisCompletionWatcher />
<PageLayout>{children}</PageLayout> <PageLayout>{children}</PageLayout>
</BaseLayout> </BaseLayout>
); );
@@ -1,9 +1,12 @@
"use client"; "use client";
import Feature from "ol/Feature.js"; import Feature from "ol/Feature.js";
import type { FeatureLike } from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import Point from "ol/geom/Point.js"; import Point from "ol/geom/Point.js";
import type OlMap from "ol/Map.js"; import type OlMap from "ol/Map.js";
import { unByKey } from "ol/Observable.js"; import { unByKey } from "ol/Observable.js";
import { bbox as bboxStrategy } from "ol/loadingstrategy.js";
import { transform } from "ol/proj.js"; import { transform } from "ol/proj.js";
import VectorLayer from "ol/layer/Vector.js"; import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js"; import VectorSource from "ol/source/Vector.js";
@@ -13,23 +16,24 @@ import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js"; import Style from "ol/style/Style.js";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Input } from "@common/components/ui";
import Gis, { useGisContext } from "@common/business_components/ui/map/Gis"; import Gis, { useGisContext } from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core"; import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type { GeoPoint } from "../_lib/types"; import type { GeoPoint, PipeMatch } from "../_lib/types";
type DredgingLocationMapProps = { type DredgingLocationMapProps = {
value: GeoPoint | null; value: GeoPoint | null;
onChange?: (point: GeoPoint) => void; onChange?: (point: GeoPoint) => void;
interactive?: boolean; interactive?: boolean;
statusText?: string;
matchedPipe?: PipeMatch | null;
}; };
type GisContextValue = { type GisContextValue = {
gisMap: OlMap | null; gisMap: OlMap | null;
}; };
const markerStyle = new Style({ const confirmedMarkerStyle = new Style({
image: new CircleStyle({ image: new CircleStyle({
radius: 9, radius: 9,
fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }), fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }),
@@ -37,35 +41,128 @@ const markerStyle = new Style({
}), }),
}); });
const draftMarkerStyle = new Style({
image: new CircleStyle({
radius: 9,
fill: new Fill({ color: "rgba(37, 99, 235, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
const nearbyPipeStyle = [
new Style({
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.95)", width: 5 }),
zIndex: 10,
}),
new Style({
stroke: new Stroke({ color: "#0f6cbd", width: 3 }),
zIndex: 11,
}),
];
const matchedPipeStyle = [
new Style({
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 8 }),
zIndex: 20,
}),
new Style({
stroke: new Stroke({ color: "#f97316", width: 5 }),
zIndex: 21,
}),
];
const PIPE_LAYER_MIN_ZOOM = 13;
const normalizePipeFid = (fid: unknown) => {
const value = String(fid ?? "").trim();
const separatorIndex = value.lastIndexOf(".");
return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
};
const getPipeFeatureStyle = (matchedPipeFid?: string) => (feature: FeatureLike) =>
normalizePipeFid(feature.getId()) === normalizePipeFid(matchedPipeFid)
? matchedPipeStyle
: nearbyPipeStyle;
/** /**
* 공통 Gis 내부 지도 객체에 클릭 interaction과 위치 marker만 추가한다. * 공통 Gis 내부 지도 객체에 관로 layer, 클릭 interaction과 위치 marker를 추가한다.
* 공통 지도 코드는 수정하지 않고 이 컴포넌트가 추가한 layer/event만 정리한다. * 공통 지도 코드는 수정하지 않고 이 컴포넌트가 추가한 layer/event만 정리한다.
*/ */
const LocationInteraction = ({ const LocationInteraction = ({
value, value,
onChange, onChange,
interactive, interactive,
matchedPipe,
}: Required<Pick<DredgingLocationMapProps, "interactive">> & }: Required<Pick<DredgingLocationMapProps, "interactive">> &
Omit<DredgingLocationMapProps, "interactive">) => { Omit<DredgingLocationMapProps, "interactive">) => {
const { gisMap } = useGisContext() as GisContextValue; const { gisMap } = useGisContext() as GisContextValue;
const layerRef = useRef<VectorLayer<VectorSource> | null>(null); const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [coordinateInput, setCoordinateInput] = useState(""); const pipeLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [pipeLoadState, setPipeLoadState] = useState<
useEffect(() => { "idle" | "loading" | "loaded" | "error"
if (!value) return; >("idle");
setCoordinateInput( const [visiblePipeCount, setVisiblePipeCount] = useState(0);
`${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}`,
);
}, [value]);
useEffect(() => { useEffect(() => {
if (!gisMap) return; if (!gisMap) return;
const source = new VectorSource(); const pipeSource = new VectorSource({
const layer = new VectorLayer({ source, style: markerStyle, zIndex: 999 }); format: new GeoJSON(),
layer.set("dredgingLocationLayer", true); strategy: bboxStrategy,
gisMap.addLayer(layer); url: (extent) => {
layerRef.current = layer; const [minX, minY, maxX, maxY] = extent;
const layerModule = GisApp.Module.layer as unknown as {
storageNm?: string;
} | null;
const storageName = String(layerModule?.storageNm ?? "hasudo:");
const params = new URLSearchParams({
SERVICE: "WFS",
VERSION: "1.1.0",
REQUEST: "GetFeature",
TYPENAME: `${storageName}tb_ms_swage_ppl`,
SRSNAME: GisApp.TO_PROJ,
OUTPUTFORMAT: "application/json",
MAXFEATURES: "2000",
CQL_FILTER: `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`,
});
return `${GisApp.GEOSERVER_URL}?${params.toString()}`;
},
});
const pipeLayer = new VectorLayer({
source: pipeSource,
minZoom: PIPE_LAYER_MIN_ZOOM,
style: getPipeFeatureStyle(matchedPipe?.fid),
updateWhileAnimating: false,
updateWhileInteracting: false,
zIndex: 700,
});
pipeLayer.set("dredgingPipeLayer", true);
pipeLayer.set("lyrTyNm", "tb_ms_swage_ppl");
pipeLayer.set("clickPsblYn", "N");
gisMap.addLayer(pipeLayer);
pipeLayerRef.current = pipeLayer;
const sourceEventKeys = [
pipeSource.on("featuresloadstart", () => setPipeLoadState("loading")),
pipeSource.on("featuresloadend", () => {
setVisiblePipeCount(pipeSource.getFeatures().length);
setPipeLoadState("loaded");
}),
pipeSource.on("featuresloaderror", () => {
setVisiblePipeCount(0);
setPipeLoadState("error");
}),
];
const markerSource = new VectorSource();
const markerLayer = new VectorLayer({
source: markerSource,
style: interactive ? draftMarkerStyle : confirmedMarkerStyle,
zIndex: 999,
});
markerLayer.set("dredgingLocationLayer", true);
gisMap.addLayer(markerLayer);
markerLayerRef.current = markerLayer;
const clickKey = interactive const clickKey = interactive
? gisMap.on("singleclick", (event) => { ? gisMap.on("singleclick", (event) => {
@@ -88,14 +185,17 @@ const LocationInteraction = ({
return () => { return () => {
if (clickKey) unByKey(clickKey); if (clickKey) unByKey(clickKey);
gisMap.removeLayer(layer); unByKey(sourceEventKeys);
layerRef.current = null; gisMap.removeLayer(markerLayer);
gisMap.removeLayer(pipeLayer);
markerLayerRef.current = null;
pipeLayerRef.current = null;
}; };
}, [gisMap, interactive, onChange]); }, [gisMap, interactive, onChange]);
useEffect(() => { useEffect(() => {
if (!gisMap || !layerRef.current) return; if (!gisMap || !markerLayerRef.current) return;
const source = layerRef.current.getSource(); const source = markerLayerRef.current.getSource();
source?.clear(); source?.clear();
if (!value) return; if (!value) return;
@@ -106,59 +206,51 @@ const LocationInteraction = ({
); );
source?.addFeature(new Feature({ geometry: new Point(coordinate) })); source?.addFeature(new Feature({ geometry: new Point(coordinate) }));
gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 }); gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 });
}, [gisMap, value]); }, [gisMap, interactive, value]);
const applyCoordinateInput = () => { useEffect(() => {
const [latitudeText, longitudeText] = coordinateInput if (!pipeLayerRef.current) return;
.split(",") pipeLayerRef.current.setStyle(getPipeFeatureStyle(matchedPipe?.fid));
.map((item) => item.trim()); pipeLayerRef.current.changed();
const latitude = Number(latitudeText); }, [matchedPipe?.fid]);
const longitude = Number(longitudeText);
if ( const pipeStatusText = (() => {
!Number.isFinite(latitude) || if (!value) return "위치를 등록하면 주변 관로가 표시됩니다.";
!Number.isFinite(longitude) || if (pipeLoadState === "loading") return "주변 관로를 불러오는 중입니다.";
latitude < -90 || if (pipeLoadState === "error") return "주변 관로를 불러오지 못했습니다.";
latitude > 90 || if (pipeLoadState === "loaded") {
longitude < -180 || return visiblePipeCount > 0
longitude > 180 ? `주변 관로 ${visiblePipeCount.toLocaleString()}건 표시`
) { : "현재 지도 범위에 표시할 관로가 없습니다.";
alert("좌표는 '위도, 경도' 형식으로 입력해주세요.");
return;
} }
onChange?.({ latitude, longitude }); return "지도를 확대하면 주변 관로가 표시됩니다.";
}; })();
return ( return (
<div className="flex min-w-[460px] items-center gap-2 rounded-sm border border-slate-300 bg-white/95 p-2 shadow"> <div className="rounded-sm border border-slate-300 bg-white/95 px-3 py-2 text-sm text-slate-700 shadow">
{interactive ? ( <p className="font-medium">
<> {interactive
<Input ? "지도를 클릭해 준설위치를 선택하세요."
aria-label="준설 위치 좌표 검색" : "등록 위치와 주변 관로를 함께 확인합니다."}
value={coordinateInput} </p>
onChange={(event) => setCoordinateInput(event.target.value)} <div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
onKeyDown={(event) => { <span className="inline-flex items-center gap-1.5">
if (event.key === "Enter") { <span className="inline-block h-1 w-6 rounded bg-[#0f6cbd]" />
event.preventDefault(); 주변 관로
applyCoordinateInput();
}
}}
placeholder="위도, 경도 입력 또는 지도 클릭"
className="!text-base"
/>
<Button
type="button"
size="sm"
onClick={applyCoordinateInput}
className="bg-blue-600 text-white hover:bg-blue-700"
>
좌표이동
</Button>
</>
) : (
<span className="px-2 text-sm text-slate-700">
등록 위치와 자동 매핑 관로를 확인합니다.
</span> </span>
)} <span className="inline-flex items-center gap-1.5">
<span className="inline-block h-1.5 w-6 rounded bg-[#f97316]" />
자동 연결 관로
</span>
<span
className={
pipeLoadState === "error" ? "text-rose-600" : "text-slate-500"
}
data-testid="dredging-pipe-map-status"
>
{pipeStatusText}
</span>
</div>
</div> </div>
); );
}; };
@@ -167,6 +259,8 @@ const DredgingLocationMap = ({
value, value,
onChange, onChange,
interactive = true, interactive = true,
statusText,
matchedPipe,
}: DredgingLocationMapProps) => { }: DredgingLocationMapProps) => {
const gisMaps = useRef<OlMap | null>(null); const gisMaps = useRef<OlMap | null>(null);
const defaultLonLat = useMemo<[number, number]>( const defaultLonLat = useMemo<[number, number]>(
@@ -192,29 +286,26 @@ const DredgingLocationMap = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
interactive={interactive} interactive={interactive}
matchedPipe={matchedPipe}
/> />
} }
defaultZoom={value ? 17 : 7.5} defaultZoom={value ? 17 : 7.5}
defaultLonLat={defaultLonLat} defaultLonLat={defaultLonLat}
height="h-[420px]" height="h-[540px]"
showZoomLevel={true} showZoomLevel={true}
showFloatingSearchButton={false} showFloatingSearchButton={false}
treeTogglePosition="top" treeTogglePosition="top"
/> />
<div <div
className="flex items-center justify-between border-t bg-slate-50 px-4 py-2 text-sm" className="border-t bg-slate-50 px-4 py-2 text-sm text-slate-700"
data-testid="dredging-map-status" data-testid="dredging-map-status"
> >
<span> {statusText ??
{interactive (interactive
? "지도 클릭 또는 위도·경도 입력으로 준설위치를 지정하세요." ? "지도에서 준설위치를 선택해주세요."
: "등록된 준설위치"} : value
</span> ? "등록된 준설위치가 표시되었습니다."
<strong className="font-medium text-slate-800"> : "위치가 등록되지 않았습니다.")}
{value
? `${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}`
: "위치 미선택"}
</strong>
</div> </div>
</div> </div>
); );
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
"use client";
import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import DredgingRegisterModal from "./DredgingRegisterModal";
/** 기존 준설 등록 흐름을 공통 WindowPopup 화면 규격으로 표시한다. */
const DredgingRegisterPopupPage = () => (
<DredgingRegisterModal
open
presentation="popup"
onSaved={(groups) => {
postPopupMessage(WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED, {
groups: JSON.stringify(groups),
});
}}
onOpenChange={(open) => {
if (!open) window.close();
}}
/>
);
export default DredgingRegisterPopupPage;
@@ -1,9 +1,10 @@
import { postRawJson } from "@/components/lib/api"; import { getRawJson, postRawJson } from "@/components/lib/api";
import { handleUpload } from "@/components/lib/utils"; import { handleUpload } from "@/components/lib/utils";
import type { HttpResponse } from "tus-js-client"; import type { HttpResponse } from "tus-js-client";
import type { import type {
DredgingSaveGroup, DredgingSaveGroup,
DredgingRecord,
GeoPoint, GeoPoint,
MatchConfidence, MatchConfidence,
PipeMatch, PipeMatch,
@@ -13,13 +14,20 @@ type LocationRequest = GeoPoint & {
locationKey: string; locationKey: string;
}; };
type AttachmentReference = { export type AttachmentReference = {
atflCd: string; atflCd: string;
atflId: string; atflId: string;
}; };
type NearestPipeResponseItem = { type NearestPipeResponseItem = {
locationKey: string; locationKey: string;
failureCode?:
| "PIPE_NOT_FOUND"
| "PIPE_ASSET_NOT_MAPPED"
| "PIPE_ASSET_AMBIGUOUS"
| string
| null;
failureMessage?: string | null;
selected?: { selected?: {
fid: string; fid: string;
mngInstCd: string; mngInstCd: string;
@@ -35,8 +43,68 @@ type NearestPipeResponseItem = {
} | null; } | null;
}; };
const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => {
switch (item.failureCode) {
case "PIPE_NOT_FOUND":
return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.";
case "PIPE_ASSET_NOT_MAPPED":
return "가장 가까운 관로는 찾았지만 자산 매핑 정보가 없습니다. 관로 자산 매핑 데이터를 확인해주세요.";
case "PIPE_ASSET_AMBIGUOUS":
return "가장 가까운 관로의 자산 매핑이 둘 이상입니다. 관로 자산 매핑 데이터를 확인해주세요.";
default:
return (
item.failureMessage?.trim() ??
"최근접 관로를 자동으로 연결하지 못했습니다."
);
}
};
export const isDredgingBackendEnabled = export const isDredgingBackendEnabled =
process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED === "true"; process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false";
const toDredgingApiError = (error: unknown, fallbackMessage: string) => {
if (!(error instanceof Error)) return new Error(fallbackMessage);
// 프록시/서버 오류가 HTML 또는 plain text로 반환되면 공통 JSON 파서의
// SyntaxError를 그대로 노출하지 않고 사용자가 이해할 수 있는 문구로 바꾼다.
if (
error instanceof SyntaxError ||
/Unexpected token|Internal Server Error|Failed to (fetch|post) data/i.test(
error.message,
)
) {
return new Error(fallbackMessage);
}
return error;
};
type DredgingRecordListResponse = {
items: DredgingRecord[];
total: number;
page: number;
size: number;
};
/** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */
export const fetchDredgingRecords = async (params: {
fromDate: string;
toDate: string;
page?: number;
size?: number;
}): Promise<DredgingRecordListResponse> => {
if (!isDredgingBackendEnabled) {
return { items: [], total: 0, page: 0, size: params.size ?? 2000 };
}
const search = new URLSearchParams({
fromDate: params.fromDate,
toDate: params.toDate,
page: String(params.page ?? 0),
size: String(params.size ?? 2000),
});
const body = await getRawJson(`v1/dredging/records?${search.toString()}`);
return (body?.data ?? body) as DredgingRecordListResponse;
};
const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => { const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
const first = locations[0]; const first = locations[0];
@@ -79,12 +147,24 @@ export const findNearestPipeMatches = async (
); );
} }
const body = await postRawJson("v1/dredging/nearestPipes", { locations }); let body: any;
try {
body = await postRawJson("v1/dredging/nearestPipes", { locations });
} catch (error) {
throw toDredgingApiError(
error,
"최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.",
);
}
const items = (body?.data ?? body) as NearestPipeResponseItem[]; const items = (body?.data ?? body) as NearestPipeResponseItem[];
const matches = new Map<string, PipeMatch>(); const matches = new Map<string, PipeMatch>();
const failureMessages: string[] = [];
for (const item of items ?? []) { for (const item of items ?? []) {
if (!item.selected) continue; if (!item.selected) {
failureMessages.push(nearestPipeFailureMessage(item));
continue;
}
matches.set(item.locationKey, { matches.set(item.locationKey, {
fid: item.selected.fid, fid: item.selected.fid,
mngInstCd: item.selected.mngInstCd, mngInstCd: item.selected.mngInstCd,
@@ -101,6 +181,10 @@ export const findNearestPipeMatches = async (
}); });
} }
if (failureMessages.length > 0) {
throw new Error(Array.from(new Set(failureMessages)).join(" "));
}
return matches; return matches;
}; };
@@ -142,12 +226,17 @@ const uploadOnePhoto = (file: File): Promise<AttachmentReference> =>
* 공통 attach-service TUS 업로드를 순차 실행한다. * 공통 attach-service TUS 업로드를 순차 실행한다.
* 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다. * 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다.
*/ */
export const uploadDredgingPhotos = async (groups: DredgingSaveGroup[]) => { export const uploadDredgingPhotos = async (
const references = new Map<string, AttachmentReference>(); groups: DredgingSaveGroup[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
const references = new Map(existingReferences);
if (!isDredgingBackendEnabled) return references; if (!isDredgingBackendEnabled) return references;
for (const group of groups) { for (const group of groups) {
for (const photo of group.photos) { for (const photo of group.photos) {
// DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다.
if (references.has(photo.key)) continue;
references.set(photo.key, await uploadOnePhoto(photo.file)); references.set(photo.key, await uploadOnePhoto(photo.file));
} }
} }
@@ -7,28 +7,38 @@
*/ */
"use client"; "use client";
import { import dynamic from "next/dynamic";
SearchDiv, import { SearchDiv } from "@common/business_components/ui/search_form";
SearchFormLeft, import { UiModal } from "@common/business_components/ui/dialog_ui";
SearchFormRight, import { UiTable } from "@common/business_components/ui/table";
UiModal,
UiTable,
} from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div"; import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui"; import { Button } from "@common/components/ui/button";
import { useMemo, useState } from "react"; import { Input } from "@common/components/ui/input";
import { Label } from "@common/components/ui/label";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import DredgingLocationMap from "./_components/DredgingLocationMap"; import {
import DredgingRegisterModal from "./_components/DredgingRegisterModal"; fetchDredgingRecords,
import { isDredgingBackendEnabled } from "./_lib/client"; isDredgingBackendEnabled,
} from "./_lib/client";
import type { import type {
DredgingRecord, DredgingRecord,
DredgingSaveGroup, DredgingSaveGroup,
DredgingViewType, DredgingViewType,
} from "./_lib/types"; } from "./_lib/types";
// OpenLayers 지도는 사용자가 지도 팝업을 열 때만 컴파일해 목록의 최초 로딩을 가볍게 유지한다.
const DredgingLocationMap = dynamic(
() => import("./_components/DredgingLocationMap"),
{ ssr: false },
);
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
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 formatLocalDate = (date: Date) => { const formatLocalDate = (date: Date) => {
const year = date.getFullYear(); const year = date.getFullYear();
@@ -215,13 +225,51 @@ const Page = () => {
const [ctpv, setCtpv] = useState("전체"); const [ctpv, setCtpv] = useState("전체");
const [sgg, setSgg] = useState("전체"); const [sgg, setSgg] = useState("전체");
const [viewType, setViewType] = useState<DredgingViewType>("MUNICIPALITY"); const [viewType, setViewType] = useState<DredgingViewType>("MUNICIPALITY");
const [records, setRecords] = useState<DredgingRecord[]>(initialRecords); const [records, setRecords] = useState<DredgingRecord[]>(
const [isRegisterOpen, setIsRegisterOpen] = useState(false); isDredgingBackendEnabled ? [] : initialRecords,
);
const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled);
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null); const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
const [isMapOpen, setIsMapOpen] = useState(false); const [isMapOpen, setIsMapOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [searchVersion, setSearchVersion] = useState(0); const [searchVersion, setSearchVersion] = useState(0);
// 조회 버튼을 누른 시점의 기간으로 실제 준설관리 API를 호출한다.
useEffect(() => {
if (!isDredgingBackendEnabled) return;
let cancelled = false;
const load = async () => {
setIsLoading(true);
setLoadError(null);
try {
const response = await fetchDredgingRecords({
fromDate,
toDate,
size: 2000,
});
if (!cancelled) setRecords(response.items ?? []);
} catch (error) {
if (!cancelled) {
setRecords([]);
setLoadError(
error instanceof Error
? error.message
: "준설현황을 조회하지 못했습니다.",
);
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
// 조회 버튼과 등록 완료 이벤트에서 searchVersion을 증가시켜 명시적으로 재조회한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchVersion]);
const filteredRecords = useMemo( const filteredRecords = useMemo(
() => () =>
records.filter( records.filter(
@@ -333,123 +381,162 @@ const Page = () => {
} }
}; };
const handleSaved = (savedGroups: DredgingSaveGroup[]) => { const handleSaved = useCallback(
const registeredDate = formatLocalDate(new Date()); (savedGroups: DredgingSaveGroup[]) => {
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({ if (isDredgingBackendEnabled) {
id: `local-${crypto.randomUUID()}`, setCurrentPage(1);
ctpv: ctpv === "전체" ? "경기도" : ctpv, setSelectedRecordId(null);
sgg: sgg === "전체" ? "양평군" : sgg, setSearchVersion((version) => version + 1);
treatmentZone: "양평처리구역", return;
fcltMngNo: group.pipe.fcltMngNo, }
pipeDiameterMm: group.pipe.pipeDiameterMm, const registeredDate = formatLocalDate(new Date());
pipeLengthM: group.pipe.pipeLengthM, const addedRecords = savedGroups.map<DredgingRecord>((group) => ({
pipeMaterial: group.pipe.pipeMaterial, id: `local-${crypto.randomUUID()}`,
installationYear: group.pipe.installationYear, ctpv: ctpv === "전체" ? "경기도" : ctpv,
dredgingDate: group.dredgingDate, sgg: sgg === "전체" ? "양평군" : sgg,
photoCount: group.photos.length, treatmentZone: "양평처리구역",
registeredDate, fcltMngNo: group.pipe.fcltMngNo,
longitude: group.location.longitude, pipeDiameterMm: group.pipe.pipeDiameterMm,
latitude: group.location.latitude, pipeLengthM: group.pipe.pipeLengthM,
registrationSource: group.locationSource, pipeMaterial: group.pipe.pipeMaterial,
matchDistanceMeters: group.pipe.distanceMeters, installationYear: group.pipe.installationYear,
matchConfidence: group.pipe.confidence, dredgingDate: group.dredgingDate,
isMock: group.pipe.isMock || !isDredgingBackendEnabled, photoCount: group.photos.length,
})); registeredDate,
setRecords((current) => [...addedRecords, ...current]); longitude: group.location.longitude,
setCurrentPage(1); latitude: group.location.latitude,
registrationSource: group.locationSource,
matchDistanceMeters: group.pipe.distanceMeters,
matchConfidence: group.pipe.confidence,
isMock: group.pipe.isMock || !isDredgingBackendEnabled,
}));
setRecords((current) => [...addedRecords, ...current]);
setCurrentPage(1);
},
[ctpv, sgg],
);
/** 공통 WindowPopup 체계로 준설결과 등록 화면을 연다. */
const openDredgingRegister = () => {
const popup = openWindowPopup(
"dredgingRegister",
{ sourcePath: window.location.pathname },
{
width: window.screen.availWidth,
height: window.screen.availHeight,
},
);
if (!popup) {
alert(
"준설결과 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
);
}
}; };
/** 등록 팝업의 저장 완료 메시지를 받아 목록에 신규 준설결과를 반영한다. */
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED) {
return;
}
try {
const savedGroups = JSON.parse(
event.data?.payload?.groups ?? "[]",
) as DredgingSaveGroup[];
if (Array.isArray(savedGroups) && savedGroups.length > 0) {
handleSaved(savedGroups);
}
} catch (error) {
console.error("준설결과 등록 팝업 응답 처리 실패", error);
}
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [handleSaved]);
return ( return (
<> <>
<div className="mb-4">
<h1 className="text-3xl font-semibold text-slate-900">하수관로 준설</h1>
</div>
<SearchDiv> <SearchDiv>
<SearchFormLeft leftClassName="m-4 min-w-0 flex-1"> <div className="flex w-full flex-wrap items-center gap-y-3 px-5 py-4">
<div className="flex w-full flex-wrap items-center gap-4"> <div className="mr-8 flex items-center gap-2">
<div className="flex items-center gap-2"> <Label className="whitespace-nowrap text-xl font-medium text-gray-600">
<Label className="whitespace-nowrap text-lg text-gray-600"> 준설일자
준설일자 </Label>
</Label> <Input
<Input type="date"
type="date" value={fromDate}
value={fromDate} max={today}
max={today} onChange={(event) => setFromDate(event.target.value)}
onChange={(event) => setFromDate(event.target.value)} className="h-[3.2rem] w-[155px] text-base"
className="w-[155px]" />
/> <span>~</span>
<span>~</span> <Input
<Input type="date"
type="date" value={toDate}
value={toDate} max={today}
max={today} onChange={(event) => setToDate(event.target.value)}
onChange={(event) => setToDate(event.target.value)} className="h-[3.2rem] w-[155px] text-base"
className="w-[155px]" />
/>
</div>
<label className="flex items-center gap-2 whitespace-nowrap text-lg text-gray-600">
조회구분
<select
value={viewType}
onChange={(event) => {
setViewType(event.target.value as DredgingViewType);
setCurrentPage(1);
setSelectedRecordId(null);
}}
className="h-10 min-w-[145px] rounded-sm border border-input bg-background px-3 text-base text-slate-900"
>
<option value="MUNICIPALITY">지자체별</option>
<option value="DIAMETER">관경별</option>
<option value="ELAPSED_YEAR">경과년도별</option>
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-lg text-gray-600">
시도
<select
value={ctpv}
onChange={(event) => {
setCtpv(event.target.value);
setSgg("전체");
}}
className="h-10 min-w-[135px] rounded-sm border border-input bg-background px-3 text-base text-slate-900"
>
{ctpvOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-lg text-gray-600">
시군구
<select
value={sgg}
onChange={(event) => setSgg(event.target.value)}
className="h-10 min-w-[125px] rounded-sm border border-input bg-background px-3 text-base text-slate-900"
>
{sggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div> </div>
</SearchFormLeft>
<SearchFormRight className="mx-4 flex shrink-0 items-center justify-center"> <label className="mr-8 flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
<div className="flex items-center gap-2"> 조회구분
<select
value={viewType}
onChange={(event) => {
setViewType(event.target.value as DredgingViewType);
setCurrentPage(1);
setSelectedRecordId(null);
}}
className="h-[3.2rem] min-w-[145px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
>
<option value="MUNICIPALITY">지자체별</option>
<option value="DIAMETER">관경별</option>
<option value="ELAPSED_YEAR">경과년도별</option>
</select>
</label>
<label className="mr-8 flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
시도
<select
value={ctpv}
onChange={(event) => {
setCtpv(event.target.value);
setSgg("전체");
}}
className="h-[3.2rem] min-w-[135px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
>
{ctpvOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="mr-8 flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
시군구
<select
value={sgg}
onChange={(event) => setSgg(event.target.value)}
className="h-[3.2rem] min-w-[125px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
>
{sggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button <Button
size="sm" size="sm"
onClick={handleSearch} onClick={handleSearch}
className="bg-blue-600 text-white hover:bg-blue-700" className={ACTION_BUTTON_CLASS}
> >
<span aria-hidden="true" className="mr-1">
⌕
</span>
조회 조회
</Button> </Button>
<Button <Button
@@ -457,42 +544,42 @@ const Page = () => {
onClick={() => { onClick={() => {
void handleDownload(); void handleDownload();
}} }}
className="green text-white" className={ACTION_BUTTON_CLASS}
> >
<span aria-hidden="true" className="mr-1">
↓
</span>
다운로드 다운로드
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={() => setIsRegisterOpen(true)} onClick={openDredgingRegister}
className="bg-blue-600 text-white hover:bg-blue-700" className={ACTION_BUTTON_CLASS}
data-testid="dredging-register-button" data-testid="dredging-register-button"
> >
<span aria-hidden="true" className="mr-1">
+
</span>
등록 등록
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" className={ACTION_BUTTON_CLASS}
disabled={!selectedRecord} disabled={!selectedRecord}
onClick={() => setIsMapOpen(true)} onClick={() => setIsMapOpen(true)}
> >
<span aria-hidden="true" className="mr-1">
◎
</span>
지도보기 지도보기
</Button> </Button>
</div> </div>
</SearchFormRight> </div>
</SearchDiv> </SearchDiv>
{loadError ? (
<div
role="alert"
className="mt-3 rounded-sm border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
>
준설현황 조회 실패: {loadError}
</div>
) : null}
<ListDiv className="mt-4"> <ListDiv className="mt-4">
<UiTable <UiTable
headName={title} headName={isLoading ? `${title} (조회 중...)` : title}
tableData={pageRows} tableData={pageRows}
headlist={headlist} headlist={headlist}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
@@ -509,12 +596,6 @@ const Page = () => {
/> />
</ListDiv> </ListDiv>
<DredgingRegisterModal
open={isRegisterOpen}
onOpenChange={setIsRegisterOpen}
onSaved={handleSaved}
/>
<UiModal <UiModal
open={isMapOpen} open={isMapOpen}
onOpenChange={setIsMapOpen} onOpenChange={setIsMapOpen}
+97
View File
@@ -0,0 +1,97 @@
import type { NextRequest } from "next/server";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const GIS_UPLOAD_PATH = "/sewerage-service/api/v1/map/gisUploads";
const HOP_BY_HOP_HEADERS = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
/**
* 대용량 multipart 본문을 middleware rewrite의 복제 단계 없이 백엔드로 그대로 전달한다.
*/
export async function POST(request: NextRequest) {
const sewerageApiUrl = process.env.SEWERAGE_API_URL?.trim();
if (!sewerageApiUrl) {
return Response.json(
{
status: 503,
message: "GIS 업로드 서버 주소가 설정되지 않았습니다.",
code: "GIS_UPLOAD_PROXY_NOT_CONFIGURED",
},
{ status: 503 },
);
}
if (!request.body) {
return Response.json(
{
status: 400,
message: "GIS 업로드 요청 본문이 없습니다.",
code: "GIS_UPLOAD_BODY_REQUIRED",
},
{ status: 400 },
);
}
const requestHeaders = new Headers(request.headers);
HOP_BY_HOP_HEADERS.forEach((headerName) => requestHeaders.delete(headerName));
requestHeaders.delete("host");
// Next가 라우트 본문을 Web Stream으로 다시 노출하므로 원 요청 길이를 전달하면 undici가 불일치로 중단할 수 있다.
requestHeaders.delete("content-length");
// undici fetch는 curl 등이 대용량 요청에 붙이는 Expect: 100-continue 전달을 지원하지 않는다.
requestHeaders.delete("expect");
// middleware rewrite와 동일하게 로그인 쿠키의 인증 값을 백엔드 요청 헤더에 주입한다.
const token = request.cookies.get(CLAIM_NAME)?.value;
const userId = request.cookies.get(AUTH_USER_ID)?.value;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
const targetUrl = `${sewerageApiUrl.replace(/\/$/, "")}${GIS_UPLOAD_PATH}`;
try {
const upstreamResponse = await fetch(targetUrl, {
method: "POST",
headers: requestHeaders,
body: request.body,
cache: "no-store",
signal: request.signal,
// Node fetch는 ReadableStream 요청 본문을 전송할 때 duplex 지정이 필요하다.
duplex: "half",
} as RequestInit & { duplex: "half" });
const responseHeaders = new Headers(upstreamResponse.headers);
HOP_BY_HOP_HEADERS.forEach((headerName) =>
responseHeaders.delete(headerName),
);
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders,
});
} catch (error) {
console.error("[GIS_UPLOAD_PROXY] 백엔드 업로드 요청 실패", error);
return Response.json(
{
status: 502,
message:
"GIS 업로드 서버 연결이 중단되었습니다. 잠시 후 다시 시도하세요.",
code: "GIS_UPLOAD_PROXY_FAILED",
},
{ status: 502 },
);
}
}
+1 -1
View File
@@ -253,7 +253,7 @@ export const postBlobData = async (
export const postMutiPart = async (url: string, data: FormData): Promise<any> => { export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
try { try {
const res = await axios.post(`${BASE_URL}/${url}`, data, { const res = await axios.post(resolveApiUrl(url), data, {
headers: DEFAULT_HEADERS, headers: DEFAULT_HEADERS,
timeout: 600000, // 10분 timeout: 600000, // 10분
}); });
@@ -1,4 +1,131 @@
# SFR-010-012 준설관리 FE 선행 검증결과 # SFR-010-012 준설관리 FE 누적 검증결과
## 0.6 2026-08-05 사진 위치 보정 이전 오류 제거 회귀검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| 테스트 사진 EXIF | 통과 | 바탕화면 `KakaoTalk_20260731_133751629.jpg`, 촬영일시 `2026-06-20T15:47:45`, GPS 약 `126.2024635, 36.8887116` 확인 |
| 사진 원위치 API | 통과 | 원본 EXIF 위치는 `PIPE_NOT_FOUND` 응답 확인 |
| 사용자 이동 위치 API | 통과 | `126.2827913451679, 36.87256459006985`는 최근접 관로를 찾고 `PIPE_ASSET_NOT_MAPPED` 응답 확인 |
| 적용 전 상태 표시 | 통과 | 새 지도 위치를 찍으면 사진 원위치의 `50m 이내 관로 없음` 오류를 숨기고 `이 위치로 관로 다시 찾기` 안내 표시 |
| 적용 후 재조회 | 통과 | 버튼 적용 후 편집 상태를 종료하고 실제 새 좌표 응답인 자산 매핑 누락 메시지로 전환 |
| 취소 회귀 | 통과 | 새 임시 좌표를 취소하면 직전 확정 위치와 관로 결과 유지 |
| 상태 메시지 초기화 | 통과 | 위치 편집 시작·좌표 변경·적용 시 이전 저장 오류 문구 제거 |
| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest`, `DredgingNearestPipeRepositoryTest` Gradle 대상 테스트 성공 |
| TypeScript 검사 | 변경 파일 통과/전체 기존 오류 | TypeScript 5.9.3과 `--ignoreDeprecations 5.0` 기준 준설 오류 0건, 준설 외 기존 오류 34건 |
| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 |
이번 문제는 최근접 관로 계산 오류가 아니라 적용 전 임시 위치와 직전 확정 위치의 오류 메시지를 동시에 보여준 프론트 상태 표시 문제였다. 새 위치 적용 후에는 해당 좌표로 서버를 다시 조회하며, 대상 관로의 SN 자산 매핑이 없으면 임의 매핑하지 않고 `PIPE_ASSET_NOT_MAPPED`를 표시한다.
## 0.5 2026-08-04 최근접 관로 표시·자산 매핑 오류 회귀검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| 사용자 선택 위치 재현 | 통과 | 수동 위치 `126.2827913451679, 36.87256459006985`를 동일하게 적용해 `PIPE_ASSET_NOT_MAPPED` 응답 재현 |
| MS 최근접 관로 | 통과 | FID `2413388`, 관리기관 `4620000`, 시설관리번호 `4401598401`, 거리 `0.941m`로 관로 자체는 정상 탐색 |
| SN 기준 데이터 대조 | 확인 필요 | `tb_sn_swage_ppl` 대상 관로 0건, `tb_sn_swage_ppl_ast_mtchn` 자산 매핑 0건으로 필수 `ast_id` 확정 불가 |
| 실패 사유 화면 표시 | 통과 | 프론트가 `PIPE_NOT_FOUND`, `PIPE_ASSET_NOT_MAPPED`, `PIPE_ASSET_AMBIGUOUS`를 구분하고 자산 매핑 누락 안내를 인라인 표시 |
| 저장 검증 메시지 | 통과 | 매핑이 없는 그룹 저장 시 일반 `자동 매핑 완료` 문구 대신 해당 그룹의 실제 매핑 실패 사유 사용 |
| 정상 매핑 API 회귀 | 통과 | 좌표 `129.0463665928, 35.4406778683`에서 관로 `2400429`, 거리 `0.000m`, HIGH 응답 유지 |
| 목록·통계 API 회귀 | 통과 | 현재일 `2026-08-04` 기준 목록 및 `MUNICIPALITY`, `DIAMETER`, `ELAPSED_YEAR` HTTP 200, 현재 데이터 0건 |
| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest` 7건, `DredgingNearestPipeRepositoryTest` 1건, 총 8건 성공 |
| TypeScript 검사 | 변경 파일 통과/전체 기존 오류 | TypeScript 5.9.3으로 전체 검사했으며 준설 변경 파일 오류는 0건. 지도관리·조사관리 등 기존 오류 34건은 별도 범위 |
| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 |
파란 선은 현재 지도 범위의 주변 관로이며, 주황색 자동 연결 관로는 최근접 관로에 단일 자산 ID까지 확정된 경우에만 표시한다. 이번 위치는 관로까지 0.941m로 가깝지만 SN 자산 기준 데이터가 없으므로 임의 `ast_id`를 생성하지 않고 등록을 차단하는 것이 DB 설계 계약과 일치한다.
## 0.4 2026-08-04 미완료 보완 및 회귀검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| 원본 요구사항 재확인 | 통과 | 화면설계서 51~55쪽 `SN_MN040201~0203`, 요구사항정의서 84~85행 `SFR-010-012/013` 직접 확인 |
| 최근접 관로 실 API | 통과 | EXIF 기준 좌표 `129.0463665928, 35.4406778683`에서 FID `3538280`, 관로 `2400429`, 거리 `0.000m`, HIGH 응답 |
| 관로 WFS 식별자 연계 | 통과 | 같은 FID의 WFS feature ID가 `tb_ms_swage_ppl.3538280`, 시설관리번호가 `2400429`임을 실 GeoServer로 확인하여 자동 연결 관로 강조 조건과 일치 |
| 프록시/서버 오류 표시 | 통과 | JSON이 아닌 500 응답의 `Unexpected token` 원문 대신 `최근접 관로를 조회하지 못했습니다...` 사용자 문구로 변환 |
| 주변 관로 표시 | 통과 | 수동 위치 선택 후 지도 줌 17, `주변 관로 128건` 및 `154건` 표시를 실제 화면에서 확인 |
| 개발 DB 등록 | 통과 | 수동 등록 1건 생성(jobSn `81`), 상세·목록 조회 후 논리 삭제 및 잔여 0건 확인 |
| 멱등 등록 | 통과 | 같은 `clientRequestId` 재호출 시 등록 0건·기등록 1건 반환 |
| 통계 API 회귀 | 통과 | `MUNICIPALITY`, `DIAMETER`, `ELAPSED_YEAR` 모두 응답 1행 확인 |
| 목록 화면 회귀 | 통과 | 지자체별·관경별·경과년도별 제목/컬럼 전환, 최종 목록 0건, 콘솔 error/warn 0건 |
| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest`, `DredgingNearestPipeRepositoryTest` 8건 통과 |
| 정적 검사 | 부분 확인 | 변경 파일 `git diff --check` 통과. 전체 `tsc --noEmit`은 60초 초과로 종료하고 해당 프로세스 트리를 정리 |
| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 |
추가 증적: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/014-준설관리-회귀검증-경과년도별.png`
Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 EXIF 사진을 다시 선택해 주황색 관로를 화면 캡처하지는 못했다. 다만 이전 실제 EXIF·최근접 관로·공통 첨부·DB 저장 검증과 이번 WFS feature ID 대조로 데이터 연계 조건을 재확인했다.
## 0.3 2026-08-04 준설 지도 관로 표시 검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| 원본 요구사항 재확인 | 통과 | 화면설계서 51~52쪽 `SN_MN040201`, 요구사항정의서 84~85행 `SFR-010-012/013` 직접 확인 |
| 기존 관망도 계약 재사용 | 통과 | `/geoserver/hasudo/ows`의 `hasudo:tb_ms_swage_ppl` WFS가 EPSG:5179 `MultiLineString`을 반환함을 확인 |
| 상세 축척 제한 | 통과 | 줌 13 미만에서는 관로 조회를 시작하지 않고, BBOX·`MAXFEATURES=2000`으로 조회 범위를 제한 |
| 주변 관로 실표시 | 통과 | 수동 위치 선택 후 브라우저 상태 `주변 관로 169건 표시`, 파란 관로 선형 렌더링 확인 |
| 위치 마커 중첩 | 통과 | 파란 관로 선형 위에 선택 위치 마커가 별도 레이어로 표시됨을 확인 |
| 자동 연결 관로 스타일 | 정적 통과 | 최근접 관로 FID와 WFS feature ID를 정규화해 일치 관로에 주황색 굵은 스타일 적용 |
| 자동 연결 관로 실행 | 백엔드 차단 | `선택 위치 적용` 시 현재 `nearestPipes`가 HTTP 500 문자열을 반환해 `Unexpected token 'I', "Internal S"...` 오류로 종료되어 주황색 강조 실행은 확인하지 못함 |
| 전체 TypeScript | 기존 오류로 실패 | 최초 전체 검사에서 변경 파일 형변환 1건을 확인·수정했으며, 나머지는 기존 지도관리·조사관리 오류. 제한 재검사는 로컬 부하로 90초 초과 |
| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 |
증적: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/013-준설등록-주변관로-표시.png`
## 0.2 2026-08-04 풀사이즈·통합 지도 UI 검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| 원본 요구사항 재확인 | 통과 | 화면설계서 50~55쪽 `SN_MN040201~0203`, 요구사항정의서 84~85행 `SFR-010-012/013` 직접 확인 |
| 호출별 팝업 크기 | 통과 | `openWindowPopup(key, params, { width, height })` 선택 옵션과 준설 호출의 `screen.availWidth/availHeight` 전달 확인 |
| 정보영역 통합 | 통과 | 사진·준설일자·위치·자동 연결 관로가 하나의 등록 카드 안에 렌더링 |
| 지도 레이어 제거 | 통과 | 등록창에 추가 `UiModal` 없이 하단 공통 `Gis` 상시 렌더링 |
| 상단 밀도 축소 | 통과 | 안내·탭·빈 상태·요약 카드 간격 축소, 사진 목록 최대 `180px` |
| 지도 확대 | 통과 | 지정 높이 `540px`, 브라우저 실측 상태바 포함 약 `566px` |
| 버튼 통일 | 통과 | 주요 등록 버튼 높이 25px, primary/secondary/danger 규칙 적용 |
| 위치 편집 전환 | 통과 | `위치등록` 클릭 시 `취소`·비활성 `선택 위치 적용` 표시 및 지도 클릭 안내 전환 |
| 취소 회귀 | 통과 | 취소 후 `위치등록` 복원, 미지정 상태와 기존 위치 유지 문구 확인 |
| 개발 서버 컴파일 | 통과 | 사용자 실행 중인 `localhost:3002`에서 변경 경로가 Build/Runtime Error 없이 렌더링 |
| 전체 TypeScript | 기존 오류로 실패 | 변경 파일 오류는 출력되지 않았고, 지도관리·조사관리 등 기존 범위 오류가 남아 있음 |
| ESLint | 환경 보류 | 설치된 의존성에서 `eslint-plugin-react-hooks`를 해석하지 못함 |
| 전체 Next build | 미수행 | 사용자 소유 3002 개발 서버 보호 기준에 따라 생략 |
증적: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/012-준설등록-풀사이즈-통합지도.png`
## 0.1 2026-08-04 백엔드·DB 실연동 검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| 실 API 목록 | 통과 | `/oper/dredging` 총 0건 → 등록 후 총 1건 → 정리 후 0건 |
| EXIF 자동 등록 | 통과 | 촬영일 `2026-08-03`, GPS, 관로 `2400429`, 거리 0.00m, HIGH |
| 공통 TUS 업로드 | 통과 | 실제 공통 첨부 코드와 36자 파일 ID를 DB에 저장 |
| 등록 후 재조회 | 통과 | 사진건수 1, 준설일자·등록일자 목록 반영 |
| 관경별 통계 | 통과 | 경상남도 0.043km, 450 이하 0.043km |
| 경과년도별 통계 | 통과 | 경상남도 0.043km, 10년 미만 0.043km |
| 목록 지도보기 | 통과 | 선택 행 위치·관로를 레이어 dialog로 표시 |
| 브라우저 콘솔 | 통과 | error/warn 0건 |
| 최신 Next 개발 컴파일 | 통과 | 목록과 등록 팝업에 Build/Runtime Error 없음 |
| XLSX 다운로드 이벤트 | 미확인 | 클릭 시 콘솔 오류는 없었으나 자동화 이벤트 포착 시간 초과 |
추가 증적: `007`~`011` PNG를 `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/`에 저장하였다.
## 0. 2026-07-31 조사관리 UI 정렬 변경 검증
| 항목 | 결과 | 근거 |
| --- | --- | --- |
| 원본 요구사항 선확인 | 통과 | 화면설계서 50~55쪽 `SN_MN040201~0203`, 요구사항정의서 84~85행 `SFR-010-012/013` 확인 |
| 조사관리 검색영역 규칙 반영 | 정적 통과 | 단일 가로 검색행, 동일 라벨·입력 높이·우측 파란 버튼군 적용 |
| 중복 화면 제목 제거 | 정적 통과 | 화면 내부 `하수관로 준설` 제목 제거 |
| 등록 WindowPopup 전환 | 정적 통과 | `dredgingRegister` 정의·레지스트리·팝업 페이지·완료 메시지 연결 |
| 등록창 지도 레이어 분리 | 정적 통과 | 등록 WindowPopup의 상시 지도 제거, `지도보기` 조회 레이어와 `위치등록/수정` 선택 레이어 분리 |
| 위치 적용 상태 분리 | 정적 통과 | 지도 클릭 위치는 임시 상태로 유지하고 `선택 위치 적용` 이후에만 최근접 관로 조회 |
| 변경 파일 제한 타입 검사 | 통과 | 변경 대상 파일 오류 0건 |
| 전체 TypeScript 검사 | 기존 오류로 실패 | 변경 대상 오류 0건, 그 외 기존 오류 34건 |
| ESLint | 환경 보류 | 설치된 의존성에서 `eslint-plugin-react-hooks`를 해석하지 못해 실행 불가 |
| 팝업 경로 응답 | 통과 | `/popup/dredgingRegister?sourcePath=%2Foper%2Fdredging` HTTP 200, 빌드 오류 문자열 없음 |
| 최신 브라우저 화면·팝업 재검증 | 차단 | Codex 인앱 브라우저가 `localhost:3002` 접근을 URL 보안 정책으로 거부 |
| 전체 Next build | 미수행 | 포트 3002 개발 서버 실행 중 보호 기준에 따라 생략 |
최신 UI 변경은 코드, 타입 검사와 실행 중인 개발 서버의 경로 응답으로 확인했으나 지도 레이어의 실제 크기·포커스·지도 클릭·취소·적용 흐름은 브라우저 재검증이 필요하다. 기존 2026-07-30 기능 흐름 검증 결과는 아래에 보존한다.
## 1. 검증 환경 ## 1. 검증 환경
@@ -45,6 +172,11 @@
## 5. 후속 검증 ## 5. 후속 검증
- 조사관리와 준설관리의 검색영역 높이·입력 정렬·버튼 위치를 같은 해상도에서 비교한다.
- 등록 버튼 클릭 시 별도 WindowPopup이 열리고 부모 화면에는 모달 오버레이가 생기지 않는지 확인한다.
- 등록 WindowPopup 하단 지도가 상시 표시되고 `위치등록/수정` 중에만 클릭 편집되는지 확인한다.
- 본문 지도 편집 취소 시 기존 위치가 유지되고 적용 시에만 최근접 관로가 갱신되는지 확인한다.
- 팝업 저장 완료 메시지 후 부모 목록 건수와 신규 행이 갱신되는지 확인한다.
- 백엔드 구현 후 공통 첨부 TUS 응답과 준설 등록 트랜잭션을 실제로 확인한다. - 백엔드 구현 후 공통 첨부 TUS 응답과 준설 등록 트랜잭션을 실제로 확인한다.
- 실제 MS 관로 geometry로 거리·스냅 좌표·동률 처리·탐색 반경을 검증한다. - 실제 MS 관로 geometry로 거리·스냅 좌표·동률 처리·탐색 반경을 검증한다.
- 서버 원본 EXIF 재분석 결과와 브라우저 분석 결과 불일치 케이스를 검증한다. - 서버 원본 EXIF 재분석 결과와 브라우저 분석 결과 불일치 케이스를 검증한다.
@@ -61,3 +61,122 @@
- 실제 파일 선택, 업로드 버튼 클릭, 행 선택 상호작용은 브라우저 수동 확인이 한 번 더 필요하다. - 실제 파일 선택, 업로드 버튼 클릭, 행 선택 상호작용은 브라우저 수동 확인이 한 번 더 필요하다.
- 저장소 기존 타입 오류 때문에 이번 기능만 분리한 정적 통과 여부를 보장하기 어렵다. - 저장소 기존 타입 오류 때문에 이번 기능만 분리한 정적 통과 여부를 보장하기 어렵다.
- 시설구분 목록은 아직 실제 시설물 레이어 조회 결과와 자동 동기화되지 않는다. - 시설구분 목록은 아직 실제 시설물 레이어 조회 결과와 자동 동기화되지 않는다.
## 5. 2026-07-31 가평군 GPKG 다운로드 오류 회귀 검증
### 5.1 요구사항 추적
- 요구사항정의서: `SFR-012-002 하수관로자료 업로드/다운로드`
- 화면설계서: 슬라이드 33, 화면 ID `SN_MN010302`
- 검증 범위: 관리기관 `가평군(4160000)` 최신 다운로드 및 이력 선택 다운로드
### 5.2 오류 원인
- Chrome 콘솔에서 최신 다운로드 요청이 간헐적으로 `TypeError: Failed to fetch`로 종료된 기록을 확인했다.
- 동일한 관리기관 요청을 다시 실행하면 정상 GPKG가 생성됐고 파일 내부 데이터도 정상이었다.
- 따라서 가평군 데이터 생성 실패가 아니라 브라우저와 Next 프록시 사이의 일시적 전송 단절을 재시도 없이 사용자 오류로 처리한 것이 직접 원인이었다.
### 5.3 수정 내용
- 현재 및 이력 다운로드의 멱등 GET 요청에 한해 네트워크 단절 시 500ms 후 1회 재시도한다.
- HTTP 4xx/5xx와 업무 검증 오류는 재시도하지 않고 기존 오류 처리로 전달한다.
- Chrome이 다운로드 클릭을 인계할 시간을 확보하도록 Blob object URL을 1초 뒤 해제한다.
### 5.4 실제 브라우저 검증 결과
- 관리기관 선택: `가평군`
- 업로드 이력 조회: 9건 표출
- 최신 다운로드: 성공 메시지 및 파일 생성 확인
- 첫 번째 업로드 이력 선택 다운로드: 성공 메시지 및 파일 생성 확인
- 검증 종료 시 Chrome `error`, `warn` 로그: 0건
### 5.5 생성 GPKG 검증 결과
- 파일명: `하수관로_가평군_20260731 (1).gpkg`, `하수관로_가평군_20260731 (2).gpkg`
- 파일 크기: 각 11,169,792 bytes
- SQLite `PRAGMA integrity_check`: `ok`
- 레이어: `tb_ms_swage_ppl`
- 피처 수: 16,670건
- 좌표계: EPSG:5179
- FID: `fid INTEGER PRIMARY KEY`, `fid_1` 없음
- 공간 인덱스: `rtree_tb_ms_swage_ppl_geom` 존재
### 5.6 정적 검증 참고
- `git diff --check`: 통과
- 실행 중인 Next 개발 서버에서 수정 화면 컴파일 및 실제 브라우저 동작: 통과
- Prettier: 저장소에 실행 파일이 없어 미수행
- TypeScript 단독 검사: 기존 `tsconfig.json`의 `ignoreDeprecations` 값과 로컬 실행 설정 오류로 시작되지 못함
## 6. 2026-08-04 업로드 오류 수정 및 전체 회귀 검증
### 6.1 요구사항 추적
- 요구사항정의서: `SFR-012-002 하수관로자료 업로드/다운로드`, `SFR-012-003 하수관로 편집`
- 화면설계서: 화면 ID `SN_MN010302`(GIS 관리), `SN_MN010301`(GIS 편집)
- 검증 시나리오: `최신 다운로드 → GeoPackage 신규·수정·삭제 → 브라우저 업로드 → 이력·미리보기·오류·재다운로드 확인`
### 6.2 재현 및 원인
- 동일한 11,169,792 byte GeoPackage를 백엔드에 직접 업로드하면 약 9.6초에 `HTTP 200`으로 완료됐다.
- 기존 `/sewerage-service/**` middleware rewrite 경로에서는 multipart 본문 전달이 60초 이상 지연됐고, 재현 요청 1건은 120.576초 후 `HTTP 500 / E999 / ERROR`로 종료됐다.
- 실패 요청은 업로드 이력과 GIS 데이터가 모두 롤백됐다.
- 원인은 대용량 multipart 본문이 일반 middleware rewrite의 요청 복제·프록시 경로를 통과한 데 있었다.
### 6.3 수정 내용
- `app/api/gis-upload/route.ts`에 Node runtime 기반 GIS 업로드 전용 스트리밍 프록시를 추가했다.
- multipart 본문을 메모리에 다시 적재하지 않고 `ReadableStream`으로 백엔드에 전달한다.
- middleware와 동일하게 로그인 쿠키 값을 인증 헤더로 전달하고, hop-by-hop 헤더와 `content-length`, `Expect`는 제거한다.
- `postMutiPart`가 `/`로 시작하는 앱 내부 경로를 `resolveApiUrl`로 처리하게 하고 GIS 관리 popup만 `/api/gis-upload`를 사용하도록 전환했다.
- 백엔드 연결 실패 시 `GIS_UPLOAD_PROXY_FAILED`와 사용자용 502 메시지를 반환한다.
### 6.4 전용 경로 검증
- 기존 middleware 경로 재현: `HTTP 500`, 120.576초, `E999 / ERROR`
- 전용 스트리밍 경로 무변경 파일: `HTTP 200`, 15.591초, 변경 0건
- 실행 중인 Next 개발 서버의 HMR 상태에서 실제 브라우저 업로드: 성공
### 6.5 신규·수정·삭제 회귀 결과
- 관리기관: `가평군(4160000)`
- 기준 피처 수: 16,670건
- 삭제: `fid=1891743`, 최종본에서 미존재 확인
- 수정: `fid=1891744`, `fclt_mng_no=QA_BROWSER_UPDATE_20260804` 확인
- 신규: `fid=4132722`, 최종본 1건 존재 확인
- 업로드 ID: `GUP-20260804165133-057BE473`
- 업로드 상태: `COMPLETED`
- 신규/수정/삭제/변경/오류: `1 / 1 / 1 / 3 / 0`
- 미리보기: 변경 관로 표출, `삭제 미표시 1건` 확인
- 오류 확인: `표시할 오류가 없습니다.` 확인
- 선택 이력 다운로드: 성공 메시지와 `HTTP 200` 확인
- 현재본/선택 이력본: 각각 16,670건, SQLite `integrity_check=ok`, 대상 FID 반영 일치
### 6.6 회귀 테스트 데이터 복원
- 회귀 검증 완료 후 테스트 직전 기준본을 동일한 전용 스트리밍 경로로 다시 업로드했다.
- 복원 업로드 ID: `GUP-20260804170432-06A6347B`, 상태 `COMPLETED`, 신규/수정/삭제/오류 `1 / 1 / 1 / 0`
- 복원 후 현재본은 16,670건, 스키마 일치, SQLite `integrity_check=ok`이며 자동 생성 FID를 제외한 업무 데이터 전체가 기준본과 일치한다.
- 테스트 신규 `fid=4132722`는 제거됐고 `fid=1891744` 수정값은 정확히 복원됐다.
- 삭제 후 재생성된 원본 업무 피처는 DB 자동 채번 정책에 따라 `fid=1891743` 대신 `fid=4132723`으로 복원됐다. 업무 속성·도형은 기준본과 동일하다.
### 6.7 정적 검증 및 제한
- 신규 route 파일 Prettier 검사: 통과
- 브라우저 console error: 0건
- 미리보기에서 오류 화면으로 전환할 때 숨겨진 OpenLayers 컨테이너 크기 0 경고 1건이 있으나 기능 실패는 없었다.
- ESLint는 기존 `eslint.config.mjs:15` 설정 예외로 실행되지 않았다.
- 기본 TypeScript 검사는 기존 `tsconfig.json`의 `ignoreDeprecations=6.0` 값 때문에 시작되지 않았고, `--ignoreDeprecations 5.0`으로 재실행하면 기존 파일 오류가 남았다. 이번 수정 파일에서 신규 타입 오류는 출력되지 않았다.
- 사용자가 실행한 Next 개발 서버를 보호하기 위해 `next build`는 수행하지 않았다.
### 6.8 증빙
- 공통 증빙 폴더: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260804/`
- 완료 화면: `17-regression-upload-completed.png`
- 미리보기: `19-regression-preview.png`
- 오류 0건: `20-regression-no-validation-errors.png`
- API 이력: `18-regression-history.json`
- 데이터 대조: `23-regression-data-verification.txt`
- 복원 응답: `28-regression-rollback-completed.json`
- 복원 후 현재본·이력: `29-current-after-rollback.gpkg`, `30-history-after-rollback.json`
- 복원 데이터 대조: `32-regression-rollback-semantic-verification.txt`
@@ -8,7 +8,7 @@
- 작성일: 2026-05-18 - 작성일: 2026-05-18
- 담당 역할: FE - 담당 역할: FE
- 관련 역할: DE, BE, QA - 관련 역할: DE, BE, QA
- 담당 범위: `apps/sn` 지도 화면에서 통수위험 진입, 결과 조회 popup, 지도보기, 설정 현황, staged 유출관로 선택, 저장 payload 준비까지 - 담당 범위: `apps/sn` 지도 화면에서 통수위험 진입과 현재 위험/과거 이력 결과 조회 popup UI까지
- 근거 문서 - 근거 문서
- `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md` - `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/001-de-SFR-011-007-통수위험분석-설계.md`
- `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md` - `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합공유기록.md`
@@ -27,9 +27,9 @@
## 2. 작업 목적 ## 2. 작업 목적
- 기존 하수관망도 지도 화면에 `통수위험` 기능을 추가해 사용자가 결과 조회, 지도보기, 설정 현황 조회, 유출관로 설정 편집을 한 흐름 안에서 수행할 수 있게 한다. - 기존 하수관망도 지도 화면에 `통수위험` 기능을 추가해 사용자가 현재 위험과 과거 이력 결과를 한 popup에서 조회할 수 있게 한다.
- 프론트는 백엔드 연결 전 단계에서 popup UI, 동적 관로 컬럼, mock 지도 강조, staged 설정 목록, 저장 payload preview까지 먼저 구현한다. - 프론트는 백엔드 연결 전 단계에서 결과 조회 popup UI와 mock 결과 데이터를 먼저 구현한다.
- 실제 산정 로직, geometry 데이터, 다운로드 파일, 저장 API는 BE 범위로 분리하고 FE는 그 계약을 맞출 준비 상태까지 수행한다. - 실제 통수량 산정 로직과 조회 API는 후속 BE/FE 공동 범위로 분리한다.
## 2-1. 최근 진행 갱신 ## 2-1. 최근 진행 갱신
@@ -173,3 +173,38 @@
- staged 설정 목록과 저장 payload preview가 준비된다. - staged 설정 목록과 저장 payload preview가 준비된다.
- FE-only 프로토타입 기준 구현이 완료되고, BE 연동 및 실제 통합 검증 후속 범위가 문서로 정리된다. - FE-only 프로토타입 기준 구현이 완료되고, BE 연동 및 실제 통합 검증 후속 범위가 문서로 정리된다.
- 지도 상세정보 카드 위치 규칙은 현재 프로토타입 기준 동작으로 마감하고, 운영 기준 확정은 후속 검증으로 넘긴다. - 지도 상세정보 카드 위치 규칙은 현재 프로토타입 기준 동작으로 마감하고, 운영 기준 확정은 후속 검증으로 넘긴다.
## 13. 2026-07-31 UI 범위 정정
이 절은 사용자 최신 지시에 따라 위의 과거 설정 기능 관련 기술을 대체한다.
### 원본 추적 근거
- 화면설계서: `REQUIREMENTS_ROOT/화면설계서.pptx` 19쪽
- 화면 ID: `SN_MN010201`
- 요구사항 ID: `SFR-011-007`
- 화면명: `통수위험분석결과 조회`
- 요구사항정의서: `REQUIREMENTS_ROOT/요구사항정의서.xlsx`
- 시트: `3. (기능)요구사항정의 및 추적표`
- 행: 93
- 요구사항 ID/명: `SFR-011-007` / `통수량 산정`
### 최신 결정
- 화면설계서 19쪽에는 `설정` 버튼이 있으나, 사용자 명시 지시에 따라 설정 버튼·설정 화면·설정 편집 흐름을 결과 popup에서 제거한다.
- popup은 `현재 위험`과 `과거 이력` 결과 조회만 제공한다.
- 검색조건은 다른 지도 popup과 같은 회색 검색 패널, 동일한 라벨/필드 정렬, 단일 `조회` 주 버튼 구조로 통일한다.
- 부모 지도 검색 컨트롤과 popup 검색 컨트롤은 모두 `Noto Sans KR`, `12.5px`, 높이 `35px`를 사용한다.
- 실제 API와 산정 기능은 이번 범위에 포함하지 않고 후속 BE/FE 공동 구현으로 남긴다.
### 검증 결과
- `http://localhost:3002/popup/flowRiskPopup`
- 설정 버튼 부재, 현재 위험/과거 이력 탭 전환, 위험상태 조회 20건 → 10건 필터링을 브라우저에서 확인했다.
- 부모 지도 `.selectbox_trigger`와 popup 날짜·선택·조회 컨트롤의 계산 스타일이 모두 `Noto Sans KR`, `12.5px`, `35px`로 일치함을 확인했다.
- 로컬 인증 API가 응답하지 않아 개발 검증용 인증 우회를 일시 적용했으며, 검증 직후 원상복구했다.
### 후속 확인 필요
- 요구사항정의서 93행은 합리식 `Q=CIA`와 Manning 공식을 요구하지만, 화면설계서 20쪽은 SWMM5 연계를 제시한다. 실제 산정 기능 설계 전에 적용 기준을 확정해야 한다.
- 2026-08-04 실제 API 연동 시 `map/page.tsx`, 세션 타입, popup 메시지 타입에 남아 있던 설정 편집·staged 관로 호환 코드도 제거해 결과 조회와 지도보기 계약만 유지했다. 상세 구현·검증은 `002-fe-SFR-011-007-통수위험분석-실API연동및검증.md`를 따른다.
@@ -0,0 +1,43 @@
# SFR-011-007 통수위험분석 FE 실 API 연동 및 검증
## 1. 목적
- 기존 FE mock 결과를 `/api/v1/waterRisk/**` 실제 응답으로 교체한다.
- 부모 지도와 popup의 기존 `sn` 글꼴·조회조건 디자인을 유지한다.
- 사용자 결정에 따라 설정 버튼·설정 화면·설정 API는 제공하지 않는다.
## 2. 구현 내용
- `WaterRiskPopupPage.tsx`
- 현재 위험/과거 이력 탭, 날짜·시도·시군구·중점관리지역·위험상태 조회
- 서버 페이지네이션, 로딩·오류·빈 결과 처리
- 현재누적강우량 원천이 없으면 `-` 표기
- 현재 탭은 예측강우량, 과거 탭은 최대강우강도 컬럼 사용
- 결과 행 선택 시 mapView 조회 후 부모창에 결과와 geometry 전달
- `waterRiskClient.ts`
- 중점관리지역, 결과 목록, mapView 실제 API 호출
- `waterRiskTypes.ts`
- 실제 API 계약, nullable 강우 필드, `RATIONAL_MANNING`, 관로별 위험상태 정의
- `map/page.tsx`
- EPSG:5179 MultiPolygon/MultiLineString 강조
- 선택 지역 fit/정보 카드 표시
- 유출유량·통수량 단위를 `㎥/s`로 통일
## 3. 검증 결과
- URL: `http://localhost:3002/popup/flowRiskPopup`
- 실제 API로 위험/정상 행, 현재/과거 탭, 위험상태 필터, 시도→시군구→중점관리지역 cascade 확인
- 결과 행 클릭 후 mapView 요청 성공과 부모 지도 강조 메시지 확인
- popup 콘솔 오류 없음
- 화면 증적: `COMMON_DOCS_ROOT/작업내용/SFR-011-007-통수위험분석/검증/001-통수위험-현재위험-실API.png`
- 앱 TypeScript 전체 검사는 통수위험 변경 파일 오류가 없었으나, 기존 타 기능의 타입 오류 때문에 전체 명령은 실패했다.
- 실행: `apps/sn/node_modules/.bin/tsc.CMD -p apps/sn/tsconfig.json --noEmit --pretty false --incremental false --ignoreDeprecations 5.0`
- 잔여 오류: `fldngHstry`, GIS 관리, `exmn`, 메뉴 alias, `map/page.tsx`의 기존 `storageNm` 등 이번 범위 밖 baseline
- 사용자 소유 Next 개발 서버는 중단하거나 빌드하지 않았다.
## 4. 영향·롤백·잔여 위험
- 영향: `components/waterRisk/**`, 부모 지도에서 통수위험 결과 메시지와 geometry 표시 부분
- 롤백: `waterRiskClient.ts`를 제거하고 popup의 실제 조회/부모 메시지 변경 및 `map/page.tsx` 통수위험 mapView 처리를 되돌린다.
- 자동화 도구가 새 창 popup을 부모 탭과 동시에 제어하는 데 제한이 있어, popup의 실제 mapView 요청과 부모 메시지 처리 코드를 각각 확인했다. 최종 사용자 회귀 시 부모 지도에서 popup을 열고 한 행을 선택하는 시나리오를 한 번 더 수행할 수 있다.
- 현재누적강우량은 백엔드 원천 미확보로 `-`이며, 예측강우 격자 overlay는 이번 합리식 결과 범위에 포함하지 않는다.
@@ -1,21 +1,90 @@
# SFR-010-012 준설관리 프론트엔드 구현기록 # SFR-010-012 준설관리 프론트엔드 구현기록
## 0.6 2026-08-05 사진 위치 보정 시 이전 관로 오류 제거
- 사진 EXIF 원위치에서 발생한 `PIPE_NOT_FOUND` 결과가 지도에서 새 위치를 선택한 뒤에도 남아 새 좌표의 조회 결과처럼 보이던 상태 표시 오류를 수정하였다.
- 확정 위치와 다른 지도 임시 좌표를 `hasUnappliedLocationDraft`로 구분하고, 적용 전에는 이전 관로·오류를 숨긴 뒤 `이 위치로 관로 다시 찾기` 안내만 표시한다.
- 위치 편집 시작, 지도 좌표 변경, 선택 위치 적용 시 이전 저장 상태 메시지를 비워 서로 다른 좌표의 결과가 섞이지 않게 하였다.
- 위치 확정과 최근접 관로 API 호출은 기존과 같이 사용자가 `이 위치로 관로 다시 찾기`를 눌렀을 때만 수행하며, 취소 시 기존 확정 위치와 매핑 결과를 유지한다.
- 테스트 사진 `KakaoTalk_20260731_133751629.jpg`의 EXIF 원위치는 `PIPE_NOT_FOUND`, 사용자가 이동한 위치는 `PIPE_ASSET_NOT_MAPPED`로 응답해 새 좌표가 실제 API에 전달됨을 확인하였다.
## 0.5 2026-08-04 자동 매핑 실패 사유 표시 보완
- 최근접 관로 API가 `selected: null`과 함께 반환하는 `failureCode`, `failureMessage`를 버리지 않고 화면 오류로 전달한다.
- `PIPE_NOT_FOUND`는 위치 재확인, `PIPE_ASSET_NOT_MAPPED`와 `PIPE_ASSET_AMBIGUOUS`는 관로 자산 매핑 데이터 확인이 필요하다는 안내로 구분한다.
- 저장 시 `최근접 관로 자동 매핑을 완료해주세요`만 반복하지 않고, 이미 확인된 그룹별 실제 실패 사유를 경고창에 표시한다.
- 사용자 선택 위치의 관로 FID `2413388`은 0.941m 거리로 탐색됐지만 SN 관로 원장과 자산 매핑이 모두 0건임을 개발 DB에서 확인했다. 필수 `ast_id`를 임의 생성하지 않고 등록을 차단한다.
## 0.4 2026-08-04 미완료 보완
- 최근접 관로 API 또는 프록시가 JSON이 아닌 오류 본문을 반환해도 공통 JSON 파서의 `Unexpected token` 문구를 화면에 노출하지 않고 준설관리용 재시도 안내로 변환한다.
- 개발 DB의 최근접 관로 FID `3538280`과 GeoServer WFS feature ID `tb_ms_swage_ppl.3538280`을 대조해 자동 연결 관로 주황색 강조의 식별자 정규화 계약을 확인했다.
- 실제 API로 수동 등록·멱등 재등록·상세·목록·3개 통계·삭제를 순차 회귀검증하고 생성한 jobSn `81`을 정리했다.
- 실제 화면에서 주변 관로 최대 154건 표시와 지자체별·관경별·경과년도별 화면 전환을 재검증했다.
## 0.3 2026-08-04 준설 지도 관로 선형 표시
- 준설 등록 하단 지도에 기존 관망도와 같은 GeoServer `tb_ms_swage_ppl` WFS 관로 선형을 추가했다.
- 현재 지도 범위의 관로만 BBOX로 조회하며, 대량 전국 조회를 막기 위해 줌 13 이상에서 최대 2,000건만 불러온다.
- 일반 주변 관로는 파란색, `POST /api/v1/dredging/nearestPipes`로 자동 연결된 관로는 FID를 대조해 주황색·굵은 선으로 강조한다.
- 위치 마커는 관로보다 높은 레이어에 유지해 준설 위치와 관로 관계를 동시에 확인할 수 있다.
- 로딩 중·오류·표시 건수를 지도 안내 영역에 표시한다.
- 공통 `Gis`와 공통 지도 패키지는 수정하지 않고 준설 전용 `DredgingLocationMap`이 추가한 WFS/마커 레이어와 이벤트만 정리한다.
- 브라우저 수동 위치 선택에서 실제 `MultiLineString` 관로 169건 표시를 확인했다.
## 0.2 2026-08-04 풀사이즈 등록 팝업·본문 통합 지도
- `openWindowPopup()`에 호출별 `width`, `height` 선택 옵션을 추가했다. URL 쿼리가 아니라 `window.open` feature로 반영한다.
- 준설 등록 호출은 `window.screen.availWidth`, `window.screen.availHeight`를 전달하고 공통 안전 여백 안에서 최대 크기로 연다.
- 사진·준설일자와 위치·자동 연결 관로를 하나의 등록 카드로 합쳤다.
- 등록창 위 지도 레이어를 제거하고 공통 `Gis` 지도를 본문 하단에 상시 표시한다.
- 평상시 지도는 조회 전용이고 `위치등록/위치 수정` 중에만 클릭 선택을 활성화한다. `취소`는 임시 위치를 버리고 `선택 위치 적용`만 최근접 관로 조회를 실행한다.
- 상단 안내·탭·사진 빈 상태·요약 카드의 패딩과 간격을 축소하고 사진 목록 최대 높이를 `180px`로 제한했다.
- 지도 높이는 `420px`에서 `540px`로 확대했다.
- 저장·관로추가·등록방식·위치·취소·삭제 버튼은 동일한 `sm` 높이와 primary/secondary/danger 규칙으로 통일했다.
## 0. 2026-08-04 실제 연동 보완
- 목록 초기값을 로컬 모의 데이터가 아닌 `GET /api/v1/dredging/records` 응답으로 전환하였다.
- 등록 완료 메시지 수신 후 부모 목록을 서버에서 다시 조회하도록 변경하였다.
- `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED`는 명시적으로 `false`인 경우만 선행 모드로 동작한다.
- 실제 EXIF 사진으로 공통 TUS 업로드, 최근접 관로, 준설 등록, DB 목록 반영을 확인하였다.
- 공통 첨부는 실제 36자 ID를 발급하므로 백엔드/DB V80 호환 보정과 함께 검증하였다.
- DB 저장 실패 재시도 시 이미 성공한 사진 업로드 참조를 `photo.key`별로 재사용하여 같은 파일을 다시 업로드하지 않도록 보완하였다.
- 지자체 목록, 관경별·경과년도별 집계, 목록 지도보기 레이어를 실제 API 데이터로 확인하였다.
## 1. 구현 결과 ## 1. 구현 결과
- 신규 직접 진입 경로: `/oper/dredging` - 신규 직접 진입 경로: `/oper/dredging`
- 기본 모드: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED`가 `true`가 아니면 FE 선행 모의 모드 - 등록 진입: 공통 WindowPopup 키 `dredgingRegister`
- 기존 지도 메인, 공통 API 모듈, 공통 컴포넌트는 수정하지 않았다. - 기본 모드: 실제 백엔드 연동이며, `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=false`를 명시한 경우만 FE 선행 모의 모드
- 조사관리 화면의 검색영역 밀도, 입력 높이, 라벨 크기, 우측 버튼군을 준설관리에도 동일하게 적용했다.
- 화면 안의 중복 제목 `하수관로 준설`을 제거하고 공통 페이지 제목 `준설관리`만 사용한다.
- 기존 지도 메인, 공통 API 모듈과 타 앱은 수정하지 않았다.
### 1.1 원본 요구사항 근거
- `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx` 슬라이드 50~55
- `SN_MN040201`: 준설 결과 업로드·위치 등록
- `SN_MN040202`: 지자체별 준설 현황
- `SN_MN040203`: 관경별·경과년도별 준설 현황
- `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx`
- 시트 `3. (기능)요구사항정의 및 추적표` 84행 `SFR-010-012`
- 같은 시트 85행 `SFR-010-013`
- 준설결과 등록 본체는 다른 업무화면과 동일한 WindowPopup으로 구성하고, 사용자의 최신 지시에 따라 풀사이즈 팝업 하단에 지도를 통합했다.
## 2. 구현 파일 ## 2. 구현 파일
| 파일 | 책임 | | 파일 | 책임 |
| --- | --- | | --- | --- |
| `page.tsx` | 지자체별·관경별·경과년도별 조회, 검색, 엑셀 다운로드, 등록/지도보기 진입 | | `page.tsx` | 지자체별·관경별·경과년도별 조회, 검색, 엑셀 다운로드, 등록/지도보기 진입 |
| `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서 | | `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서와 팝업 표시 모드 |
| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 로컬 위치 마커와 지도 클릭 interaction 연결 | | `_components/DredgingRegisterPopupPage.tsx` | 등록 완료 메시지 전송과 팝업 창 닫기 |
| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 주변 관로 WFS, 자동 연결 관로 강조, 위치 마커와 지도 클릭 interaction 연결 |
| `_lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 | | `_lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 |
| `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터와 FE 모의 응답 | | `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터, API 오류 정규화와 FE 모의 응답 |
| `_lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 | | `_lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 |
| `src/features/windowPopup/**` | `dredgingRegister` 정의·레지스트리·완료 메시지 계약 |
## 3. 주요 처리 ## 3. 주요 처리
@@ -31,11 +100,13 @@
### 3.2 수동 등록 ### 3.2 수동 등록
1. `지도 수동 등록`을 선택한다. 1. `사진 없이 직접 등록`을 선택한다.
2. 준설일자를 입력한다. 2. 준설일자를 입력한다.
3. 공통 지도 클릭 또는 `위도, 경도` 입력으로 위치를 지정한다. 3. 등록창 하단의 `위치등록`을 눌러 같은 화면의 지도를 편집 상태로 전환한다.
4. 좌표 변경 즉시 최근접 관로와 거리·신뢰도를 갱신한다. 4. 사용자는 좌표값을 직접 입력하지 않고 지도에서 위치를 클릭한다.
5. 관로 매핑이 완료된 건만 저장 payload로 만든다. 5. `선택 위치 적용`을 눌렀을 때만 선택 위치를 확정하고 최근접 관로와 거리·신뢰도를 갱신한다.
6. 편집을 취소하면 적용하지 않은 위치는 폐기하고 기존 위치를 유지한다.
7. 관로 매핑이 완료된 건만 저장 payload로 만든다.
### 3.3 FE 선행 모드 ### 3.3 FE 선행 모드
@@ -44,6 +115,31 @@
- 사용자 화면에는 개발단계, 선행 구현, 모의 매핑 안내 문구를 노출하지 않는다. - 사용자 화면에는 개발단계, 선행 구현, 모의 매핑 안내 문구를 노출하지 않는다.
- 등록 결과는 현재 탭의 목록 상태에만 반영한다. - 등록 결과는 현재 탭의 목록 상태에만 반영한다.
### 3.4 조사관리 UI 정렬 및 WindowPopup
1. 검색조건을 조사관리와 같은 단일 가로 flex 행으로 배치한다.
2. 라벨은 `text-xl`, 입력 높이는 `3.2rem`, 선택상자는 흰색 `rounded-lg` 규칙을 사용한다.
3. 조회·다운로드·등록·지도보기 버튼을 조사관리와 같은 파란색 액션 버튼으로 통일하고 장식 아이콘을 제거한다.
4. 등록 버튼은 `/popup/dredgingRegister` 창에 화면 가용 크기를 호출 옵션으로 전달해 풀사이즈에 가깝게 연다.
5. 저장 성공 시 팝업이 같은 출처의 `sn:popup:dredgingRegistered` 메시지를 부모 화면에 전달한다.
6. 부모 화면은 메시지의 등록 그룹을 기존 목록 상태에 반영하고 첫 페이지로 이동한다.
### 3.5 본문 통합 지도
1. 준설결과 등록 WindowPopup의 사진·준설정보와 위치·관로 확인을 하나의 카드로 구성한다.
2. 공통 `Gis` 지도는 위치·관로 요약 바로 아래에 상시 표시한다.
3. 확정 위치가 있으면 `위치 수정`, 위치가 없으면 `위치등록`을 제공한다.
4. 위치 편집 중 지도 클릭 위치는 임시 상태로 유지하고 `선택 위치 적용` 이후에만 등록 데이터와 최근접 관로 조회에 반영한다.
5. 목록 화면의 `지도보기`는 기존 `UiModal` 레이어 팝업 방식을 유지한다.
### 3.6 관로 선형 표시
1. 위치가 없을 때는 전국 관로를 조회하지 않고 위치 등록 안내만 표시한다.
2. 위치가 등록되거나 지도에서 임시 위치를 선택해 줌 17로 이동하면 현재 화면 BBOX의 `tb_ms_swage_ppl` WFS를 조회한다.
3. WFS의 `LineString/MultiLineString`은 파란색 관로 선형으로 표시한다.
4. 최근접 관로 응답의 숫자 FID와 WFS feature ID(`tb_ms_swage_ppl.{fid}`)를 정규화해 같은 관로를 주황색으로 강조한다.
5. 위치 마커는 관로 위에 표시하고, 관로 layer는 클릭 대상에서 제외해 위치 선택 흐름을 방해하지 않게 한다.
## 4. 연동 전환 ## 4. 연동 전환
- 환경변수: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=true` - 환경변수: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=true`
@@ -55,5 +151,8 @@
## 5. 롤백 ## 5. 롤백
- `apps/sn/app/(with-base-layout)/oper/dredging/**`를 제거한다. - `page.tsx`의 호출별 가용 화면 크기 옵션을 제거하면 팝업 정의의 기본 크기로 되돌릴 수 있다.
- `DredgingRegisterModal.tsx`의 본문 하단 `DredgingLocationMap`을 제거하면 통합 지도 구성을 롤백할 수 있다.
- `DredgingRegisterModal.tsx`의 `presentation="popup"` 분기와 `DredgingRegisterPopupPage.tsx`를 제거한다.
- `src/features/windowPopup`의 `dredgingRegister` 정의·레지스트리·메시지 타입을 제거한다.
- 신규 전용 경로이므로 기존 지도·운영정보 화면의 코드 롤백은 필요하지 않다. - 신규 전용 경로이므로 기존 지도·운영정보 화면의 코드 롤백은 필요하지 않다.
@@ -114,3 +114,34 @@
- 실제 upload history 스키마가 설계 초안과 달라질 수 있다. - 실제 upload history 스키마가 설계 초안과 달라질 수 있다.
- 현재 업로드/다운로드는 요청 미리보기와 mock 목록 갱신만 수행하므로, 실제 파일 바이너리 흐름은 백엔드 구현 후 재검증이 필요하다. - 현재 업로드/다운로드는 요청 미리보기와 mock 목록 갱신만 수행하므로, 실제 파일 바이너리 흐름은 백엔드 구현 후 재검증이 필요하다.
- 시설구분 목록은 현재 `UPLOAD_FACILITY_TYPES` mock 상수에 고정돼 있어 실제 좌측 `시설물 레이어`와 다를 수 있다. - 시설구분 목록은 현재 `UPLOAD_FACILITY_TYPES` mock 상수에 고정돼 있어 실제 좌측 `시설물 레이어`와 다를 수 있다.
## 7. 2026-07-31 가평군 다운로드 안정화
### 7.1 작업 배경
- 실제 FE-BE 연동이 완료된 GIS 관리 화면에서 가평군 최신 GPKG 다운로드가 간헐적으로 실패했다.
- Chrome 콘솔의 실패 기록은 HTTP 업무 오류가 아닌 `TypeError: Failed to fetch`였다.
- 같은 요청의 다음 실행은 정상 파일을 반환해 대용량 응답 전송 구간의 일시적 연결 단절로 판단했다.
### 7.2 구현 내용
- `GisManagementPopupPage.tsx`에 GIS 다운로드 전용 호출 함수를 추가했다.
- 네트워크 단절로 식별되는 `TypeError`만 500ms 후 한 번 재시도한다.
- 현재 다운로드와 업로드 이력 선택 다운로드가 같은 안정화 로직을 사용한다.
- HTTP 오류와 서버 업무 오류는 재시도 대상에서 제외한다.
- Blob object URL은 다운로드 클릭 직후가 아닌 1초 뒤 해제한다.
### 7.3 영향 범위
- 변경 파일: `apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx`
- 변경 API 계약: 없음
- 백엔드 및 DB 변경: 없음
- 다른 다운로드 화면과 공통 API 유틸리티 영향: 없음
### 7.4 검증 요약
- 가평군 최신 GPKG 다운로드 성공
- 가평군 업로드 이력 선택 다운로드 성공
- 두 파일 모두 16,670건, EPSG:5179, SQLite 무결성 `ok`
- `fid INTEGER PRIMARY KEY` 단일 컬럼과 공간 인덱스 확인
- 최종 브라우저 콘솔 오류 및 경고 없음
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3002", "dev": "next dev --turbopack -p 3002",
"dev:webpack": "next dev -p 3002",
"build": "next build", "build": "next build",
"start:sn": "next start -p 3002", "start:sn": "next start -p 3002",
"lint": "next lint" "lint": "next lint"
+16 -7
View File
@@ -3,15 +3,23 @@
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview"; import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import dynamic from "next/dynamic";
import { AuthWrapper } from "@/entities/auth"; import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu"; import { MenuWrapper } from "@/entities/menu";
import { PromptProvider, StoreProvider } from "@/shared/providers"; import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog"; import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { GlobalAnalysisCompletionWatcher } from "../../../app/(with-base-layout)/ii/useAnalysisCompletionWatcher";
// 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다.
const LazyReactQueryDevtools = dynamic(
() =>
import("@tanstack/react-query-devtools").then(
({ ReactQueryDevtools }) => ReactQueryDevtools,
),
{ ssr: false },
);
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -29,7 +37,6 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
{children} {children}
</DndProvider> </DndProvider>
</MenuWrapper> </MenuWrapper>
<GlobalAnalysisCompletionWatcher />
<AlertDialog /> <AlertDialog />
<PromptDialog /> <PromptDialog />
<ConfirmDialog /> <ConfirmDialog />
@@ -37,11 +44,13 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
</ConfirmProvider> </ConfirmProvider>
</PromptProvider> </PromptProvider>
</StoreProvider> </StoreProvider>
<div className="text-2xl"> {process.env.NODE_ENV === "development" ? (
<ReactQueryDevtools initialIsOpen={false} /> <div className="text-2xl">
</div> <LazyReactQueryDevtools initialIsOpen={false} />
</div>
) : null}
</QueryClientProvider> </QueryClientProvider>
); );
}; };
export { AppProvider }; export { AppProvider };
+24
View File
@@ -64,6 +64,30 @@
@apply flex min-h-0 flex-1 flex-col overflow-hidden p-4; @apply flex min-h-0 flex-1 flex-col overflow-hidden p-4;
} }
/* 부모 업무화면의 10px rem 기준과 동일한 가독성을 준설 등록 팝업에도 적용한다. */
.sn-dredging-register {
font-size: 1.3rem;
line-height: 1.5;
}
.sn-dredging-register .text-xs {
font-size: 1.1rem;
}
.sn-dredging-register .text-sm,
.sn-dredging-register button,
.sn-dredging-register input {
font-size: 1.3rem;
}
.sn-dredging-register .text-base {
font-size: 1.4rem;
}
.sn-dredging-register .text-xl {
font-size: 1.8rem;
}
.sn-popup-filter-panel { .sn-popup-filter-panel {
@apply grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4; @apply grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4;
} }
@@ -3,11 +3,9 @@
import type { UIEvent } from "react"; import type { UIEvent } from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import type { UiTableSort } from "@common/business_components/ui/table";
downloadExcelWithKoreanHeaders,
type UiTableSort,
} from "@common/business_components/ui";
import { Button, Input } from "@common/components/ui"; import { Button, Input } from "@common/components/ui";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { import {
getExmnFacilityDetails, getExmnFacilityDetails,
@@ -3,7 +3,11 @@ import {
windowPopupDefinitions, windowPopupDefinitions,
type WindowPopupKey, type WindowPopupKey,
} from "../model/popupDefinitions"; } from "../model/popupDefinitions";
import type { OpenWindowPopupParams, WindowPopupDefinition } from "../model/types"; import type {
OpenWindowPopupOptions,
OpenWindowPopupParams,
WindowPopupDefinition,
} from "../model/types";
const buildQueryString = (params: OpenWindowPopupParams = {}) => { const buildQueryString = (params: OpenWindowPopupParams = {}) => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@@ -84,13 +88,18 @@ const buildPopupFeatures = ({
export const openWindowPopup = ( export const openWindowPopup = (
popupKey: WindowPopupKey, popupKey: WindowPopupKey,
params?: OpenWindowPopupParams, params?: OpenWindowPopupParams,
options?: OpenWindowPopupOptions,
) => { ) => {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
if (!isWindowPopupKey(popupKey)) return null; if (!isWindowPopupKey(popupKey)) return null;
const definition = windowPopupDefinitions[popupKey]; const definition = windowPopupDefinitions[popupKey];
const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`; const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`;
const popupFeatures = buildPopupFeatures(definition); const popupFeatures = buildPopupFeatures({
...definition,
width: options?.width ?? definition.width,
height: options?.height ?? definition.height,
});
const popupWindow = window.open( const popupWindow = window.open(
popupUrl, popupUrl,
`sn-popup-${popupKey}`, `sn-popup-${popupKey}`,
@@ -21,6 +21,12 @@ export const windowPopupDefinitions = {
height: 720, height: 720,
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"], requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
}, },
dredgingRegister: {
key: "dredgingRegister",
title: "준설결과 등록",
width: 1280,
height: 900,
},
oldPpUpload: { oldPpUpload: {
key: "oldPpUpload", key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록", title: "노후 하수관로 정비사업 등록",
@@ -175,8 +181,8 @@ export const windowPopupDefinitions = {
flowRiskPopup: { flowRiskPopup: {
key: "flowRiskPopup", key: "flowRiskPopup",
title: "통수 위험", title: "통수 위험",
width: 920, width: 1180,
height: 760, height: 820,
}, },
} as const satisfies Record<string, WindowPopupDefinition>; } as const satisfies Record<string, WindowPopupDefinition>;
@@ -30,10 +30,16 @@ export type OpenWindowPopupParams = Record<
string | number | boolean | null | undefined string | number | boolean | null | undefined
>; >;
/** 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기만 재정의한다. */
export type OpenWindowPopupOptions = Partial<
Pick<WindowPopupDefinition, "width" | "height">
>;
export const WINDOW_POPUP_MESSAGE_TYPE = { export const WINDOW_POPUP_MESSAGE_TYPE = {
EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated", EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated",
EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated", EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated",
EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted", EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted",
DREDGING_REGISTERED: "sn:popup:dredgingRegistered",
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted", OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
@@ -49,11 +55,6 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
WATER_RISK_READY: "sn:popup:waterRiskReady", WATER_RISK_READY: "sn:popup:waterRiskReady",
WATER_RISK_SYNC: "sn:popup:waterRiskSync", WATER_RISK_SYNC: "sn:popup:waterRiskSync",
WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult", WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult",
WATER_RISK_START_SETTING_EDIT: "sn:popup:waterRiskStartSettingEdit",
WATER_RISK_STOP_SETTING_EDIT: "sn:popup:waterRiskStopSettingEdit",
WATER_RISK_REMOVE_STAGED_PIPE: "sn:popup:waterRiskRemoveStagedPipe",
WATER_RISK_REORDER_STAGED_PIPE: "sn:popup:waterRiskReorderStagedPipe",
WATER_RISK_CLEAR_STAGED_PIPES: "sn:popup:waterRiskClearStagedPipes",
} as const; } as const;
export type WindowPopupMessageType = export type WindowPopupMessageType =
@@ -2,6 +2,7 @@ import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefini
import type { WindowPopupRegistryItem } from "./model/types"; import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent"; import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent"; import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
import DredgingRegisterPopupContent from "./ui/DredgingRegisterPopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent"; import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent"; import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent"; import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
@@ -50,6 +51,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.exmnSurveyDetail, ...windowPopupDefinitions.exmnSurveyDetail,
component: ExmnSurveyDetailPopupContent, component: ExmnSurveyDetailPopupContent,
}, },
dredgingRegister: {
...windowPopupDefinitions.dredgingRegister,
component: DredgingRegisterPopupContent,
},
oldPpUpload: { oldPpUpload: {
...windowPopupDefinitions.oldPpUpload, ...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent, component: OldPpUploadPopupContent,
@@ -0,0 +1,19 @@
"use client";
import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage";
import { useEffect, useState } from "react";
/** 준설결과 등록 화면을 공통 WindowPopup 레이아웃에 연결한다. */
export default function DredgingRegisterPopupContent() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
}
return <DredgingRegisterPopupPage />;
}
+125
View File
@@ -0,0 +1,125 @@
type ExcelDownloadWithHeaderProps = {
data: JSON | JSON[];
fileName: string;
sheetNameArray: string[];
headerRange?: string;
headerMapping: Record<string, string>;
mergeRanges?: string[];
};
type ExcelRow = Record<string, unknown>;
// 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다.
const normalizeSheetName = (sheetName: string | undefined) => {
const normalized = (sheetName || "Sheet1").replace(/[\\/?*[\]:]/g, "_");
return normalized.slice(0, 31) || "Sheet1";
};
// 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다.
const getDisplayWidth = (value: unknown) =>
Array.from(String(value ?? "")).reduce(
(width, character) => width + (character.charCodeAt(0) > 255 ? 2 : 1),
0,
);
/**
* Turbopack이 지원하지 않는 xlsx-populate의 동적 require를 피하면서
* 기존 한글 헤더, 병합 범위와 XLSX 다운로드 계약을 유지한다.
*/
export const downloadExcelWithKoreanHeaders = async ({
data,
fileName,
sheetNameArray,
headerRange = "A1:V1",
headerMapping,
mergeRanges = [],
}: ExcelDownloadWithHeaderProps) => {
if (typeof window === "undefined") {
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
}
const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[];
if (dataRows.length === 0) {
throw new Error("데이터가 없습니다.");
}
// XLSX 모듈도 다운로드 버튼을 누를 때만 불러와 지도 화면의 최초 컴파일을 줄인다.
const XLSX = await import("xlsx");
const englishHeaders = Object.keys(dataRows[0] ?? {});
const koreanHeaders = englishHeaders.map(
(header) => headerMapping[header] || header,
);
const worksheetRows = [
koreanHeaders,
...dataRows.map((row) => englishHeaders.map((header) => row[header])),
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
const decodedHeaderRange = XLSX.utils.decode_range(headerRange);
for (
let rowIndex = decodedHeaderRange.s.r;
rowIndex <= decodedHeaderRange.e.r;
rowIndex += 1
) {
for (
let columnIndex = decodedHeaderRange.s.c;
columnIndex <= decodedHeaderRange.e.c;
columnIndex += 1
) {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: columnIndex,
});
const headerCell = worksheet[cellAddress] as
| (typeof worksheet[string] & { s?: Record<string, unknown> })
| undefined;
if (headerCell) {
headerCell.s = {
fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } },
font: { bold: true, sz: 13 },
alignment: {
horizontal: "center",
vertical: "center",
wrapText: true,
},
border: {
top: { style: "thin", color: { rgb: "FFFFFF" } },
bottom: { style: "thin", color: { rgb: "FFFFFF" } },
left: { style: "thin", color: { rgb: "FFFFFF" } },
right: { style: "thin", color: { rgb: "FFFFFF" } },
},
};
}
}
}
worksheet["!autofilter"] = { ref: headerRange };
worksheet["!merges"] = mergeRanges.map((range) =>
XLSX.utils.decode_range(range),
);
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
const sampledValues = dataRows
.slice(0, 200)
.map((row) => row[header])
.concat(koreanHeaders[columnIndex]);
const width = Math.max(...sampledValues.map(getDisplayWidth), 10);
return { wch: Math.min(width + 2, 40) };
});
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
worksheet,
normalizeSheetName(sheetNameArray[0]),
);
const normalizedFileName = fileName.toLowerCase().endsWith(".xlsx")
? fileName
: `${fileName}.xlsx`;
XLSX.writeFile(workbook, normalizedFileName, {
bookType: "xlsx",
compression: true,
cellStyles: true,
});
};
@@ -78,6 +78,7 @@ function Gis({
showFloatingSearchButton = true, showFloatingSearchButton = true,
treeTogglePosition = "center", treeTogglePosition = "center",
detachedTreeToggleButton = false, detachedTreeToggleButton = false,
showLayerPanelTitle = true,
showZoomScaleInfo = false, showZoomScaleInfo = false,
}) { }) {
const mapRef = useRef(); const mapRef = useRef();
@@ -324,6 +325,7 @@ function Gis({
treeSectionClassName={treeSectionClassName} treeSectionClassName={treeSectionClassName}
toggleButtonPosition={treeTogglePosition} toggleButtonPosition={treeTogglePosition}
detachedToggleButton={detachedTreeToggleButton} detachedToggleButton={detachedTreeToggleButton}
showLayerPanelTitle={showLayerPanelTitle}
/> />
{typeof WaterDepth === "function" && ( {typeof WaterDepth === "function" && (