Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic
This commit is contained in:
@@ -9,9 +9,5 @@ export default function AuthLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
return children;
|
||||
}
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import {
|
||||
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE,
|
||||
buildAgedPipeStatusByCtpvHeadList,
|
||||
buildAgedPipeStatusYearOptions,
|
||||
normalizeAgedPipeStatusByCtpvResponse,
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
toAgedPipeStatusByCtpvRows,
|
||||
validateAgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvResponse,
|
||||
type AgedPipeStatusByCtpvRow,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
import { downloadAgedPipeStatusByCtpvCsv } from "./agedPipeStatusByCtpvExport";
|
||||
|
||||
export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
// 조회조건 입력 상태다. 기본값 30년, 최근 연도 기준 단년 조회로 시작한다.
|
||||
const [filters, setFilters] = useState<AgedPipeStatusByCtpvFilters>(() =>
|
||||
normalizeAgedPipeStatusByCtpvFilters({
|
||||
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
}),
|
||||
);
|
||||
// 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다.
|
||||
const [rows, setRows] = useState<AgedPipeStatusByCtpvRow[]>([]);
|
||||
// 동적 컬럼 생성을 위한 기준년도 목록이다.
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
// 조회/다운로드 중 로딩 overlay를 제어한다.
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// 사용자에게 현재 다운로드 상태를 보여주는 진행 문구다.
|
||||
const [downloadProgressText, setDownloadProgressText] = useState("");
|
||||
|
||||
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
|
||||
const headList = useMemo(() => buildAgedPipeStatusByCtpvHeadList(years), [years]);
|
||||
|
||||
const loadRows = useCallback(async (nextFilters: AgedPipeStatusByCtpvFilters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(nextFilters);
|
||||
const queryParams = new URLSearchParams({
|
||||
startYear: String(normalized.startYear),
|
||||
endYear: String(normalized.endYear),
|
||||
elapsedYears: String(normalized.elapsedYears),
|
||||
});
|
||||
const rawResponse = await getRawJson(
|
||||
`v1/swagePpl/aged-status-by-ctpv?${queryParams.toString()}`,
|
||||
);
|
||||
const response = normalizeAgedPipeStatusByCtpvResponse(rawResponse) as AgedPipeStatusByCtpvResponse;
|
||||
setYears(Array.isArray(response?.years) ? response.years : []);
|
||||
setRows(toAgedPipeStatusByCtpvRows(response));
|
||||
} catch (error) {
|
||||
console.error("시도별 노후관로현황 조회 중 오류:", error);
|
||||
setYears([]);
|
||||
setRows([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(filters);
|
||||
}, [filters, loadRows]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
if (!validateAgedPipeStatusByCtpvFilters(normalized)) {
|
||||
alert("기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
setFilters(normalized);
|
||||
await loadRows(normalized);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setDownloadProgressText("시도별 노후관로현황 CSV를 준비하고 있습니다.");
|
||||
await downloadAgedPipeStatusByCtpvCsv(filters);
|
||||
setDownloadProgressText("");
|
||||
} catch (error) {
|
||||
console.error("시도별 노후관로현황 다운로드 중 오류:", error);
|
||||
setDownloadProgressText("");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "시도별 노후관로현황 CSV 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">시도별 노후관로현황</h1>
|
||||
<div />
|
||||
</header>
|
||||
|
||||
{downloadProgressText ? (
|
||||
<div className="sn-popup-success-bar">
|
||||
{downloadProgressText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[160px_160px_160px_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">기준년도 시작</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.startYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) =>
|
||||
normalizeAgedPipeStatusByCtpvFilters({
|
||||
...current,
|
||||
startYear: Number(event.target.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">기준년도 종료</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={filters.endYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
endYear: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">노후관로 기준(년)</div>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={String(filters.elapsedYears)}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
elapsedYears: Number(event.target.value.replace(/[^0-9]/g, "")),
|
||||
}))
|
||||
}
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions md:pr-3">
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
>
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-auto overflow-y-auto pt-2">
|
||||
<UiTable<AgedPipeStatusByCtpvRow>
|
||||
// 조회 결과 총 {rows.length.toLocaleString()}건
|
||||
headName={<>시도별 노후관로현황 </>}
|
||||
tableData={rows}
|
||||
headlist={headList}
|
||||
pageSize={AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE}
|
||||
total={rows.length}
|
||||
cellClick={() => undefined}
|
||||
onPageChange={() => undefined}
|
||||
curPage={1}
|
||||
index={-1}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
cellClassName={() => "text-[1.2rem]"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { postBlobData } from "@/components/lib/api";
|
||||
import { fileDownload } from "@/components/lib/utils/fileDownload";
|
||||
import {
|
||||
normalizeAgedPipeStatusByCtpvFilters,
|
||||
type AgedPipeStatusByCtpvFilters,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
|
||||
/** 시도별 노후관로현황 CSV 다운로드를 요청한다. */
|
||||
export const downloadAgedPipeStatusByCtpvCsv = async (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => {
|
||||
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
const { blob, fileName } = await postBlobData("v1/swagePpl/aged-status-by-ctpv/exportJobs/download", {
|
||||
startYear: normalizedFilters.startYear,
|
||||
endYear: normalizedFilters.endYear,
|
||||
elapsedYears: normalizedFilters.elapsedYears,
|
||||
format: "csv",
|
||||
});
|
||||
|
||||
const resolvedFileName = fileName || "시도별_노후관로현황.csv";
|
||||
fileDownload(blob, resolvedFileName);
|
||||
return resolvedFileName;
|
||||
};
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
|
||||
export const AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS = 30;
|
||||
export const AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN = 3;
|
||||
export const AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE = 50;
|
||||
|
||||
export type AgedPipeStatusByCtpvFilters = {
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
elapsedYears: number;
|
||||
};
|
||||
|
||||
export type AgedPipeStatusByCtpvYearStat = {
|
||||
year: number;
|
||||
totalLengthKm: number;
|
||||
agedLengthKm: number;
|
||||
agedRate: number;
|
||||
};
|
||||
|
||||
export type AgedPipeStatusByCtpvRow = {
|
||||
rowNumber: number;
|
||||
ctpvCd: string;
|
||||
ctpvNm: string;
|
||||
yearStats: AgedPipeStatusByCtpvYearStat[];
|
||||
[key: string]: string | number | AgedPipeStatusByCtpvYearStat[];
|
||||
};
|
||||
|
||||
export type AgedPipeStatusByCtpvResponse = {
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
elapsedYears: number;
|
||||
years: number[];
|
||||
rows: Array<{
|
||||
ctpvCd: string;
|
||||
ctpvNm: string;
|
||||
yearStats: AgedPipeStatusByCtpvYearStat[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const normalizeAgedPipeStatusByCtpvResponse = (
|
||||
response: Partial<AgedPipeStatusByCtpvResponse> | null | undefined,
|
||||
): AgedPipeStatusByCtpvResponse => ({
|
||||
startYear: Number(response?.startYear ?? 0),
|
||||
endYear: Number(response?.endYear ?? 0),
|
||||
elapsedYears: Number(response?.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS),
|
||||
years: Array.isArray(response?.years) ? response.years.map((year) => Number(year)) : [],
|
||||
rows: Array.isArray(response?.rows) ? response.rows : [],
|
||||
});
|
||||
|
||||
export const buildAgedPipeStatusYearOptions = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: currentYear - 1900 + 1 }, (_, index) => currentYear - index);
|
||||
};
|
||||
|
||||
export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
filters: Partial<AgedPipeStatusByCtpvFilters>,
|
||||
): AgedPipeStatusByCtpvFilters => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
|
||||
let endYear = Math.trunc(Number(filters.endYear ?? startYear));
|
||||
let elapsedYears = Math.trunc(
|
||||
Number(filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS),
|
||||
);
|
||||
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear;
|
||||
if (!Number.isFinite(endYear)) endYear = startYear;
|
||||
if (!Number.isFinite(elapsedYears) || elapsedYears < 1 || elapsedYears > 100) {
|
||||
elapsedYears = AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS;
|
||||
}
|
||||
if (startYear > endYear) {
|
||||
const swap = startYear;
|
||||
startYear = endYear;
|
||||
endYear = swap;
|
||||
}
|
||||
if (endYear - startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) {
|
||||
endYear = startYear + (AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
startYear,
|
||||
endYear,
|
||||
elapsedYears,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateAgedPipeStatusByCtpvFilters = (filters: AgedPipeStatusByCtpvFilters) => {
|
||||
if (filters.startYear > filters.endYear) return false;
|
||||
if (filters.endYear - filters.startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) return false;
|
||||
return filters.elapsedYears >= 1 && filters.elapsedYears <= 100;
|
||||
};
|
||||
|
||||
const formatMetric = (value: number) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const toAgedPipeStatusByCtpvRows = (
|
||||
response: Partial<AgedPipeStatusByCtpvResponse> | null | undefined,
|
||||
): AgedPipeStatusByCtpvRow[] =>
|
||||
normalizeAgedPipeStatusByCtpvResponse(response).rows.map((row, index) => {
|
||||
const displayRow: AgedPipeStatusByCtpvRow = {
|
||||
rowNumber: index + 1,
|
||||
ctpvCd: String(row.ctpvCd ?? "").trim(),
|
||||
ctpvNm: String(row.ctpvNm ?? "").trim(),
|
||||
yearStats: row.yearStats ?? [],
|
||||
};
|
||||
|
||||
(row.yearStats ?? []).forEach((yearStat) => {
|
||||
displayRow[`year${yearStat.year}TotalLengthKm`] = formatMetric(Number(yearStat.totalLengthKm ?? 0));
|
||||
displayRow[`year${yearStat.year}AgedLengthKm`] = formatMetric(Number(yearStat.agedLengthKm ?? 0));
|
||||
displayRow[`year${yearStat.year}AgedRate`] = formatMetric(Number(yearStat.agedRate ?? 0));
|
||||
});
|
||||
|
||||
return displayRow;
|
||||
});
|
||||
|
||||
export const buildAgedPipeStatusByCtpvHeadList = (years: number[]): UiHeadProps[] => {
|
||||
const heads: UiHeadProps[] = [
|
||||
// { id: "rowNumber", title: "NO", widthPercent: 6, sortable: false },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 14, sortable: false },
|
||||
];
|
||||
|
||||
years.forEach((year) => {
|
||||
heads.push(
|
||||
{
|
||||
id: `year${year}TotalLengthKm`,
|
||||
title: `${year} 전체관로연장(km)`,
|
||||
widthPercent: 13,
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: `year${year}AgedLengthKm`,
|
||||
title: `${year} 노후관로연장(km)`,
|
||||
widthPercent: 13,
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: `year${year}AgedRate`,
|
||||
title: `${year} 노후화율(%)`,
|
||||
widthPercent: 11,
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return heads;
|
||||
};
|
||||
@@ -61,6 +61,7 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
{ key: "infoSearch", label: "정보검색", icon: Search },
|
||||
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
|
||||
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
|
||||
{ key: "agedPipeStatusByCtpvPopup", label: "시도별 노후관로현황", icon: ChartColumnBig },
|
||||
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
|
||||
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
|
||||
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
|
||||
|
||||
@@ -484,17 +484,20 @@ export const FldngWtlvMapCol = [
|
||||
{ id: 'fldng_dstr_nm', title: '침수재해명' },
|
||||
{ id: 'ctpv_nm', title: '시도명' },
|
||||
{ id: 'sgg_nm', title: '시군구명' },
|
||||
{ id: 'bgng_ymd', title: '침수시작일자' },
|
||||
{ id: 'fldng_bgng_hm', title: '침수시작시분' },
|
||||
{ id: 'end_ymd', title: '침수종료일자' },
|
||||
{ id: 'fldng_end_hm', title: '침수종료시분' },
|
||||
{ id: 'fldng_bgng_dtm', title: '침수시작일시' },
|
||||
{ id: 'fldng_end_dtm', title: '침수종료일시' },
|
||||
{ id: 'fldng_hr', title: '침수시간' },
|
||||
{ id: 'fldng_dstrct_nm', title: '침수지구명' },
|
||||
{ id: 'fldng_cs_dtl_cd', title: '침수원인상세코드' },
|
||||
{ id: 'dtl_ocrn_cs_cn', title: '침수발생원인' },
|
||||
{ id: 'avg_fldng_wtlv', title: '평균침수위' },
|
||||
{ id: 'fldng_area', title: '침수면적' },
|
||||
{ id: 'exmn_yr', title: '조사년도' },
|
||||
{ id: 'dam_amt', title: '피해금액' },
|
||||
{ id: 'rain_bgng_tm', title: '강우시작일시' },
|
||||
{ id: 'rain_end_tm', title: '강우종료일시' },
|
||||
{ id: 'rain_tot', title: '총강우량(mm)' },
|
||||
{ id: 'rain_str', title: '강우강도(mm/hr)' },
|
||||
];
|
||||
export const FldngWtlvMapCol2 = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
@@ -502,10 +505,8 @@ export const FldngWtlvMapCol2 = [
|
||||
{ id: 'fldngDstrNm', title: '침수재해명' },
|
||||
{ id: 'ctpvNm', title: '시도' },
|
||||
{ id: 'sggNm', title: '시군구' },
|
||||
{ id: 'bgngYmd', title: '침수시작일자' },
|
||||
{ id: 'fldngBgngHm', title: '침수시작시분' },
|
||||
{ id: 'endYmd', title: '침수종료일자' },
|
||||
{ id: 'fldngEndHm', title: '침수종료시분' },
|
||||
{ id: 'fldngBgngDtm', title: '침수시작일시' },
|
||||
{ id: 'fldngEndDtm', title: '침수종료일시' },
|
||||
{ id: 'fldngHr', title: '침수시간' },
|
||||
{ id: 'fldngDstrctNm', title: '침수지구명' },
|
||||
{ id: 'fldngCsDtlCd', title: '침수원인상세코드' },
|
||||
@@ -513,6 +514,10 @@ export const FldngWtlvMapCol2 = [
|
||||
{ id: 'fldngArea', title: '침수면적' },
|
||||
{ id: 'exmnYr', title: '조사년도' },
|
||||
{ id: 'damAmt', title: '피해금액' },
|
||||
{ id: 'rainBgngTm', title: '강우시작일시' },
|
||||
{ id: 'rainEndTm', title: '강우종료일시' },
|
||||
{ id: 'rainTot', title: '총강우량(mm)' },
|
||||
{ id: 'rainStr', title: '강우강도(mm/hr)' },
|
||||
];
|
||||
export const FldngWtlvMapExcelCol = {
|
||||
'fldngYr': '침수년도',
|
||||
|
||||
@@ -8,9 +8,11 @@ import { Button, Input } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import {
|
||||
FLOOD_HISTORY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_DEFAULT_FILTERS,
|
||||
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
|
||||
FLDNG_HSTRY_DOWNLOAD_HEADERS,
|
||||
@@ -46,6 +48,15 @@ const resolveFldngHstryApiSort = (sort?: UiTableSort) => {
|
||||
return { key: sort.key, direction: sort.direction } as UiTableSort;
|
||||
};
|
||||
|
||||
const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
|
||||
const fid = String(row.fid ?? "").trim();
|
||||
if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
|
||||
if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`;
|
||||
|
||||
const mapFid = String(row.fldngMapFid ?? "").trim();
|
||||
return mapFid || "";
|
||||
};
|
||||
|
||||
const buildDownloadRows = (rows: FldngHstryTableRow[]) =>
|
||||
rows.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
@@ -200,6 +211,22 @@ export default function FldngHstryPopupPage() {
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
|
||||
const mapFeatureFid = resolveFldngHstryMapFeatureFid(row);
|
||||
if (mapFeatureFid) {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
|
||||
payload: {
|
||||
layerNm: FLOOD_HISTORY_LAYER_TYPE,
|
||||
fid: mapFeatureFid,
|
||||
},
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: FLDNG_HSTRY_ROW_SELECT_MESSAGE,
|
||||
|
||||
@@ -27,36 +27,36 @@ export type FldngHstryDepthZone = {
|
||||
};
|
||||
|
||||
export type FldngHstryApiItem = {
|
||||
fid: string;
|
||||
fldngMapFid?: string;
|
||||
fldngWtlvMapSn?: number | null;
|
||||
taskClCd?: string;
|
||||
fldngDstrNm?: string;
|
||||
exmnYr?: string;
|
||||
avgFldngWtlv?: number | null;
|
||||
fldngArea?: number | null;
|
||||
dtlOcrnCsCn?: string;
|
||||
stdgCd?: string;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
dataSn?: number | null;
|
||||
bgngYmd?: string;
|
||||
endYmd?: string;
|
||||
fldngBgngHm?: string;
|
||||
fldngEndHm?: string;
|
||||
fldngCsDtlCd?: string;
|
||||
fldngDstrctNm?: string;
|
||||
damAmt?: number | null;
|
||||
fldngHr?: number | null;
|
||||
fldngYr?: string;
|
||||
cstrnFbctnYn?: string;
|
||||
atchFileSn?: number | null;
|
||||
confArea?: number | null;
|
||||
confLen?: number | null;
|
||||
rainBgngTm?: string | null;
|
||||
rainEndTm?: string | null;
|
||||
rainTot?: number | null;
|
||||
rainStr?: number | null;
|
||||
fid: string; // FID
|
||||
fldngMapFid?: string; // 침수흔적도지도 FID
|
||||
fldngWtlvMapSn?: number | null; // 침수흔적도지도 일련번호
|
||||
taskClCd?: string; // 업무 구분 코드
|
||||
fldngDstrNm?: string; // 침수재해명
|
||||
exmnYr?: string; // 조사년도
|
||||
avgFldngWtlv?: number | null; // 평균침수위
|
||||
fldngArea?: number | null; // 침수면적
|
||||
dtlOcrnCsCn?: string; // 상세발생원인내용
|
||||
stdgCd?: string; // 표준지역코드
|
||||
ctpvCd?: string; // 시도코드
|
||||
sggCd?: string; // 시군구코드
|
||||
dataSn?: number | null; // 데이터 일련번호
|
||||
bgngYmd?: string; // 시작일자
|
||||
endYmd?: string; // 종료일자
|
||||
fldngBgngHm?: string; // 침수시작시분
|
||||
fldngEndHm?: string; // 침수종료시분
|
||||
fldngCsDtlCd?: string; // 침수원인상세코드
|
||||
fldngDstrctNm?: string; // 침수지구명
|
||||
damAmt?: number | null; // 피해금액
|
||||
fldngHr?: number | null; // 침수시간
|
||||
fldngYr?: string; // 침수년도
|
||||
cstrnFbctnYn?: string; // 공사여부
|
||||
atchFileSn?: number | null; // 첨부파일 일련번호
|
||||
confArea?: number | null; // 확정면적
|
||||
confLen?: number | null; // 확정연장
|
||||
rainBgngTm?: string | null; // 강우시작일시
|
||||
rainEndTm?: string | null; // 강우종료일시
|
||||
rainTot?: number | null; // 총강우량
|
||||
rainStr?: number | null; // 강우강도
|
||||
};
|
||||
|
||||
export type FldngHstryItem = FldngHstryApiItem & {
|
||||
@@ -120,20 +120,20 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
|
||||
|
||||
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "NO", widthPercent: 5, sortable: false },
|
||||
{ id: "floodYearDisplay", title: "침수년도", widthPercent: 6, sortable: true, sortKey: "fldngYr" },
|
||||
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 6, sortable: true, sortKey: "fldngYr" },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 7, sortable: true, sortKey: "ctpvCd" },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 8, sortable: true, sortKey: "sggCd" },
|
||||
{ id: "disasterName", title: "침수재해명", widthPercent: 14, sortable: true, sortKey: "fldngDstrNm" },
|
||||
{ id: "rainBgngTmDisplay", title: "강우시작일시", widthPercent: 10, sortable: true, sortKey: "rainBgngTm" },
|
||||
{ id: "rainEndTmDisplay", title: "강우종료일시", widthPercent: 10, sortable: true, sortKey: "rainEndTm" },
|
||||
{ id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 10, sortable: true, sortKey: "rainBgngTm" },
|
||||
{ id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 10, sortable: true, sortKey: "rainEndTm" },
|
||||
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8, sortable: true, sortKey: "rainTot" },
|
||||
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8, sortable: true, sortKey: "rainStr" },
|
||||
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7, sortable: true, sortKey: "fldngHr" },
|
||||
{ id: "districtName", title: "침수지구명", widthPercent: 10, sortable: true, sortKey: "fldngDstrctNm" },
|
||||
{ id: "causeDetailCode", title: "침수원인상세코드", widthPercent: 8, sortable: true, sortKey: "fldngCsDtlCd" },
|
||||
{ id: "causeDetailName", title: "침수원인상세", widthPercent: 12, sortable: false },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균침수위", widthPercent: 7, sortable: true, sortKey: "avgFldngWtlv" },
|
||||
{ id: "floodAreaDisplay", title: "침수면적", widthPercent: 7, sortable: true, sortKey: "fldngArea" },
|
||||
//{ id: "causeDetailCode", title: "침수원인상세코드", widthPercent: 8, sortable: true, sortKey: "fldngCsDtlCd" },
|
||||
//{ id: "causeDetailName", title: "침수원인상세", widthPercent: 12, sortable: false },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7, sortable: true, sortKey: "avgFldngWtlv" },
|
||||
{ id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7, sortable: true, sortKey: "fldngArea" },
|
||||
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6, sortable: true, sortKey: "exmnYr" },
|
||||
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9, sortable: true, sortKey: "damAmt" },
|
||||
];
|
||||
@@ -291,6 +291,7 @@ const normalizeHm = (value?: string | null) => {
|
||||
const normalizeDateTime = (value?: string | null) => {
|
||||
const source = String(value ?? "").trim();
|
||||
if (!source) return "";
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
|
||||
const date = new Date(source);
|
||||
if (Number.isNaN(date.getTime())) return source.slice(0, 16).replace("T", " ");
|
||||
const yyyy = date.getFullYear();
|
||||
@@ -313,6 +314,13 @@ const formatInteger = (value?: number | null, suffix = "") => {
|
||||
return `${numeric.toLocaleString("ko-KR")}${suffix}`;
|
||||
};
|
||||
|
||||
const formatOptionalNumber = (value?: number | null, digits = 1, suffix = "") => {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return "-";
|
||||
return `${numeric.toFixed(digits)}${suffix}`;
|
||||
};
|
||||
|
||||
const buildCauseDetailName = (item: FldngHstryApiItem) => {
|
||||
const direct = String(item.dtlOcrnCsCn ?? "").trim();
|
||||
if (direct) return direct;
|
||||
@@ -361,9 +369,12 @@ export const buildFldngHstryListQueryString = (
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
|
||||
export const formatFloodArea = (value: number) => Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
|
||||
export const formatFloodDepth = (value: number) => value.toFixed(2);
|
||||
|
||||
export const attachFldngHstryGeometry = (item: FldngHstryApiItem): FldngHstryItem => {
|
||||
const historyId = String(item.fid ?? "");
|
||||
@@ -392,10 +403,10 @@ export const attachFldngHstryGeometry = (item: FldngHstryApiItem): FldngHstryIte
|
||||
endDate,
|
||||
startTime,
|
||||
endTime,
|
||||
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1, "시간"),
|
||||
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
|
||||
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
|
||||
causeDetailName: buildCauseDetailName(item),
|
||||
damageAmount: formatInteger(Number(item.damAmt ?? 0), "원"),
|
||||
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
|
||||
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
|
||||
rainEndDateTime: normalizeDateTime(item.rainEndTm),
|
||||
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
|
||||
@@ -422,16 +433,16 @@ export const toFldngHstryTableRows = (
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: (page - 1) * pageSize + index + 1,
|
||||
floodYearDisplay: item.floodYear ? `${item.floodYear}년` : "-",
|
||||
floodYearDisplay: item.floodYear || "-",
|
||||
floodHourDisplay: item.floodHour || "-",
|
||||
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
|
||||
exmnYearDisplay: item.exmnYear ? `${item.exmnYear}년` : "-",
|
||||
exmnYearDisplay: item.exmnYear || "-",
|
||||
damageAmountDisplay: item.damageAmount || "-",
|
||||
rainBgngTmDisplay: item.rainStartDateTime || "-",
|
||||
rainEndTmDisplay: item.rainEndDateTime || "-",
|
||||
rainTotDisplay: formatNumber(item.totalRainfallMm, 1),
|
||||
rainStrDisplay: formatNumber(item.rainfallIntensityMmHr, 1),
|
||||
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
|
||||
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
|
||||
}));
|
||||
|
||||
const resolveFldngHstryNodeName = (node: any) =>
|
||||
|
||||
@@ -32,13 +32,23 @@ import Fill from "ol/style/Fill.js";
|
||||
import Stroke from "ol/style/Stroke.js";
|
||||
import CircleStyle from "ol/style/Circle.js";
|
||||
import { getLayerContentComponent } from "../common";
|
||||
import { FldngWtlvMapCol, FldngWtlvMapCol2, SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
|
||||
import { FldngWtlvMapCol, FldngWtlvMapCol2
|
||||
, SwtrmtFcltCol, SwtrmtFcltCol2
|
||||
, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2
|
||||
, untrtSwgRainCol1, untrtSwgRainCol2
|
||||
, MsSwagePplCol, MsSwagePplCol2
|
||||
, ExclnSewagePumpCol, ExclnSewagePumpCol2
|
||||
, DrtwSewagePumpCol, DrtwSewagePumpCol2
|
||||
, MsSwageMhlCol, MsSwageMhlCol2
|
||||
, stofcbrCol, stofcbrCol2
|
||||
, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import { codeMapper } from "../common/codeMapper";
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar';
|
||||
import { searchUpperPpls } from "./smh";
|
||||
import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
const linkedFacilityHighlightStyle = new Style({
|
||||
image: new CircleStyle({
|
||||
@@ -67,6 +77,45 @@ const normalizeFldngWtlvMapDisplayId = (value: unknown) => {
|
||||
return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized;
|
||||
};
|
||||
|
||||
const normalizeFldngWtlvMapApiFid = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized) return "";
|
||||
return normalizeFldngWtlvMapDisplayId(normalized);
|
||||
};
|
||||
|
||||
const normalizeRainAnalysisPayload = (data: any) => {
|
||||
if (!data || typeof data !== "object") return {};
|
||||
return {
|
||||
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
|
||||
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
|
||||
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
|
||||
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
|
||||
rain_tot: data.rainTot ?? data.rain_tot ?? null,
|
||||
rainTot: data.rainTot ?? data.rain_tot ?? null,
|
||||
rain_str: data.rainStr ?? data.rain_str ?? null,
|
||||
rainStr: data.rainStr ?? data.rain_str ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
// 침수흔적도 침수시작일시, 침수종료일시 한줄로 보이도록
|
||||
const formatFldngWtlvDateTime = (dateValue: unknown, timeValue: unknown) => {
|
||||
const rawDate = String(dateValue ?? "").trim();
|
||||
const rawTime = String(timeValue ?? "").trim();
|
||||
|
||||
const normalizedDate = /^\d{8}$/.test(rawDate)
|
||||
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
|
||||
: rawDate;
|
||||
|
||||
const timeDigits = rawTime.replace(/[^0-9]/g, "");
|
||||
const normalizedTime = !timeDigits
|
||||
? ""
|
||||
: timeDigits.length <= 2
|
||||
? `${timeDigits.padStart(2, "0")}:00`
|
||||
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
|
||||
|
||||
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
type MngInstOption = {
|
||||
mngInstCd: string;
|
||||
lwrInstNm: string;
|
||||
@@ -92,6 +141,7 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
const linkedFacilityFeatureRef = useRef<any>(null);
|
||||
const linkedFacilityOriginalStyleRef = useRef<any>(null);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
|
||||
// 🎯 관로관리번호 추출
|
||||
const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "";
|
||||
@@ -149,6 +199,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
};
|
||||
}, [isPipeFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const mngInstNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
mngInstOptions.forEach((option) => {
|
||||
@@ -169,6 +224,24 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
};
|
||||
};
|
||||
|
||||
const withResolvedRegionNames = (data: any) => {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
const stdgCd = String(data.stdg_cd ?? data.stdgCd ?? "").trim();
|
||||
const ctpvCd = String(data.ctpv_cd ?? data.ctpvCd ?? stdgCd.slice(0, 2)).trim();
|
||||
const sggCd = String(data.sgg_cd ?? data.sggCd ?? stdgCd.slice(0, 5)).trim();
|
||||
const resolvedCtpvNm = ctpv.find((item) => String(item.val).trim() === ctpvCd)?.text ?? "";
|
||||
const resolvedSggNm = sgg.find((item) => String(item.val).trim() === sggCd)?.text ?? "";
|
||||
|
||||
return {
|
||||
...data,
|
||||
ctpv_nm: data.ctpv_nm ?? data.ctpvNm ?? resolvedCtpvNm,
|
||||
ctpvNm: data.ctpvNm ?? data.ctpv_nm ?? resolvedCtpvNm,
|
||||
sgg_nm: data.sgg_nm ?? data.sggNm ?? resolvedSggNm,
|
||||
sggNm: data.sggNm ?? data.sgg_nm ?? resolvedSggNm,
|
||||
};
|
||||
};
|
||||
|
||||
const hasDetailValue = (value: unknown) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
@@ -446,7 +519,25 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
}
|
||||
|
||||
// 🎯 기본 데이터 포맷팅
|
||||
const formattedData = withMngInstName(formatDisplayData(data));
|
||||
const normalizedRegionData = selectedLayerName === '침수흔적도'
|
||||
? withResolvedRegionNames(data)
|
||||
: data;
|
||||
const formattedData = withMngInstName(formatDisplayData(normalizedRegionData));
|
||||
if (selectedLayerName === '침수흔적도') {
|
||||
const fldngBgngDtm = formatFldngWtlvDateTime(
|
||||
formattedData.bgngYmd ?? formattedData.bgng_ymd ?? data.bgngYmd ?? data.bgng_ymd,
|
||||
formattedData.fldngBgngHm ?? formattedData.fldng_bgng_hm ?? data.fldngBgngHm ?? data.fldng_bgng_hm,
|
||||
);
|
||||
const fldngEndDtm = formatFldngWtlvDateTime(
|
||||
formattedData.endYmd ?? formattedData.end_ymd ?? data.endYmd ?? data.end_ymd,
|
||||
formattedData.fldngEndHm ?? formattedData.fldng_end_hm ?? data.fldngEndHm ?? data.fldng_end_hm,
|
||||
);
|
||||
|
||||
formattedData.fldng_bgng_dtm = fldngBgngDtm;
|
||||
formattedData.fldngBgngDtm = fldngBgngDtm;
|
||||
formattedData.fldng_end_dtm = fldngEndDtm;
|
||||
formattedData.fldngEndDtm = fldngEndDtm;
|
||||
}
|
||||
if (selectedLayerName === '하수관로') {
|
||||
const installYmd = data.instl_ymd ?? data.instlYmd;
|
||||
formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd);
|
||||
@@ -556,6 +647,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
|
||||
console.warn("Pipe detail fallback kept initial feature data.");
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayerName === '침수흔적도' && featureId) {
|
||||
try {
|
||||
const normalizedFloodMapFid = normalizeFldngWtlvMapApiFid(featureId);
|
||||
if (!normalizedFloodMapFid) {
|
||||
throw new Error("침수흔적도 fid가 비어 있습니다.");
|
||||
}
|
||||
const rainAnalysis = await getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(normalizedFloodMapFid)}`);
|
||||
if (rainAnalysis) {
|
||||
const mergedData = {
|
||||
...formattedData,
|
||||
...normalizeRainAnalysisPayload(rainAnalysis),
|
||||
};
|
||||
setFetchData(mergedData);
|
||||
setTableData(mergedData);
|
||||
}
|
||||
} catch (rainError) {
|
||||
console.warn("침수강우분석 조회 실패:", rainError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로딩 중 오류:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# SFR-011-001 시도별 노후관로현황 FE 검증 기록
|
||||
|
||||
## 1. 정적 확인
|
||||
- 분석도구 메뉴, popup definition, popup registry, popup page 연결을 코드 기준으로 확인했다.
|
||||
- 동적 연도 컬럼 생성 로직과 CSV 다운로드 호출 경로를 확인했다.
|
||||
|
||||
## 2. 실행 확인
|
||||
- `pnpm --filter sn build`
|
||||
- 결과: 시간 제한 내 완료되지 않아 최종 성공/실패를 확정하지 못했다.
|
||||
- 비고: 이 저장소는 기존에도 FE build가 환경 영향으로 지연 또는 `EMFILE` 이슈가 있었으므로 사용자 로컬 실행 확인이 추가로 필요하다.
|
||||
@@ -0,0 +1,19 @@
|
||||
# SFR-011-001 시도별 노후관로현황 FE 작업카드
|
||||
|
||||
## 1. 작업 목적
|
||||
- 하수관망도 우측 분석도구에서 `시도별 노후관로현황` 팝업을 열고 기준년도 구간별 시도 집계를 조회한다.
|
||||
|
||||
## 2. 구현 범위
|
||||
- `AnalysisPopupMenu`에 진입 버튼 추가
|
||||
- `windowPopup` registry에 새 popup key 추가
|
||||
- 전용 popup page 구현
|
||||
- 기준년도 시작/종료, 경과년도 기본값 30년, CSV 다운로드 연결
|
||||
|
||||
## 3. 구현 기준
|
||||
- 기존 `노후관로 현황` popup의 레이아웃 톤과 다운로드 UX를 최대한 재사용한다.
|
||||
- 집계형 표이므로 지도 feature 선택 연동은 넣지 않는다.
|
||||
- 기준년도 범위는 최대 3년까지만 허용한다.
|
||||
|
||||
## 4. 연동 API
|
||||
- `GET /api/v1/swagePpl/aged-status-by-ctpv`
|
||||
- `POST /api/v1/swagePpl/aged-status-by-ctpv/exportJobs/download`
|
||||
@@ -0,0 +1,22 @@
|
||||
# SFR-011-001 시도별 노후관로현황 FE 통합 구현 기록
|
||||
|
||||
## 1. 구현 요약
|
||||
- 분석도구 메뉴에 `시도별 노후관로현황` 항목을 추가했다.
|
||||
- 새 popup key `agedPipeStatusByCtpvPopup`와 registry content를 연결했다.
|
||||
- 전용 popup page에서 기준년도 시작/종료, 경과년도(기본 30년), 조회, CSV 다운로드를 구현했다.
|
||||
- 응답의 `years + rows.yearStats` 구조를 동적 컬럼 `UiTable` row로 변환하도록 유틸을 추가했다.
|
||||
|
||||
## 2. 주요 수정 파일
|
||||
- `app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
|
||||
- `src/features/windowPopup/model/popupDefinitions.ts`
|
||||
- `src/features/windowPopup/registry.tsx`
|
||||
- `src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx`
|
||||
- `app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx`
|
||||
- `app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts`
|
||||
- `app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts`
|
||||
|
||||
## 3. 화면 동작
|
||||
- popup 제목은 `시도별 노후관로현황`이다.
|
||||
- 기준년도 범위는 최대 3년으로 제한한다.
|
||||
- 경과년도는 기본 `30년`으로 시작하고, 후속에 사용자가 변경 가능한 입력 구조를 미리 반영했다.
|
||||
- CSV 다운로드는 기존 노후관로 popup과 동일하게 `postBlobData + fileDownload` 흐름을 따른다.
|
||||
@@ -71,3 +71,15 @@
|
||||
- popup 라우트의 초기 컴파일 시간이 길어 자동 요청에서 timeout이 발생할 수 있다.
|
||||
- `침수흔적도` 기준데이터 이름/구조와 FE 판별식이 다를 수 있다.
|
||||
- 실제 API 필드가 확정되면 상세패널 항목명/정렬이 일부 바뀔 수 있다.
|
||||
|
||||
## 7. 2026-07-28 지도 클릭 강우분석 조회 연동
|
||||
|
||||
- 반영 파일
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/common/table.ts`
|
||||
- 구현 내용
|
||||
- 지도에서 `침수흔적도(tb_ms_fldng_wtlv_map)`를 클릭해 우측 상세를 열면, 기존 원본 속성값만 보여주던 구조에 `fid` 기준 강우분석 조회를 추가했다.
|
||||
- FE는 `GET /api/v1/fldng/hstry/rain-anls/{fid}`를 추가 호출한 뒤 `tb_sn_fldng_wtlv_rain_anls` 응답을 상세 테이블 데이터에 merge한다.
|
||||
- 침수흔적도 상세 컬럼에 `강우시작일시`, `강우종료일시`, `총강우량(mm)`, `강우강도(mm/hr)`를 추가했다.
|
||||
- 영향
|
||||
- 레이어 트리 popup 목록과 별도로, 실제 지도 객체 클릭 상세에서도 강우분석 정보를 동일하게 확인할 수 있다.
|
||||
|
||||
@@ -72,6 +72,7 @@ export function middleware(req: NextRequest) {
|
||||
typename == "hasudo:tb_il_swage_prcs_fclt" //하수처리시설
|
||||
|| typename == "hasudo:tb_ms_swage_prcs_zone" //하수처리구역
|
||||
|| typename == "hasudo:tb_ms_fldng_wtlv_map" //침수흔적도
|
||||
|| typename == "hasudo:tb_ms_fldng_dpth_map" //침수흔적도-깊이
|
||||
|| typename == "hasudo:tb_ms_sbmrg_dngr_map" //침수위험지도
|
||||
|| typename == "hasudo:tb_ms_flood_dngr_map" //도시침수지도
|
||||
) {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import "./styles/base.css";
|
||||
import "./styles/components.css";
|
||||
import "./styles/components-black.css";
|
||||
import "./styles/components-blue.css";
|
||||
import "./styles/components-green.css";
|
||||
import "./styles/components-red.css";
|
||||
import { AppProvider } from "./providers";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
|
||||
}
|
||||
.card-border {
|
||||
@apply border border-border bg-background shadow-blur;
|
||||
}
|
||||
.card-header {
|
||||
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
|
||||
}
|
||||
.card-footer {
|
||||
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
@apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
|
||||
}
|
||||
.card-border {
|
||||
@apply border border-border bg-background shadow-blur;
|
||||
}
|
||||
.card-header {
|
||||
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
|
||||
}
|
||||
.card-footer {
|
||||
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
@apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
|
||||
}
|
||||
.card-border {
|
||||
@apply border border-border bg-background shadow-blur;
|
||||
}
|
||||
.card-header {
|
||||
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
|
||||
}
|
||||
.card-footer {
|
||||
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
@apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-label;
|
||||
}
|
||||
.card-border {
|
||||
@apply border border-border bg-background shadow-blur;
|
||||
}
|
||||
.card-header {
|
||||
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-border p-4;
|
||||
}
|
||||
.card-footer {
|
||||
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-border py-4;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
@apply h-[40px] w-[200px] cursor-pointer rounded-md border border-input bg-field px-3 py-2 text-sm text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,148 @@
|
||||
.sn-popup-help-text {
|
||||
@apply mt-2 rounded-md border border-border bg-secondary px-4 py-3 text-[1.05rem] text-label;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
@apply mt-4 border border-border bg-[#eaf3fb] p-2 text-center text-xl font-medium text-[#647784];
|
||||
}
|
||||
|
||||
[data-theme="black"] {
|
||||
.card {
|
||||
color: #fff;
|
||||
background-color: #0a0a0a !important;
|
||||
}
|
||||
form {
|
||||
color: rgb(255 255 255);
|
||||
}
|
||||
label {
|
||||
color: rgb(241, 241, 241);
|
||||
}
|
||||
input {
|
||||
height: 3.2rem;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 4px;
|
||||
}
|
||||
input.text-gray-600 {
|
||||
color: #f1f1f1;
|
||||
}
|
||||
button[role="combobox"] {
|
||||
color: #f1f1f1;
|
||||
background-color: rgb(26, 26, 26);
|
||||
}
|
||||
div [role="tablist"] {
|
||||
border: unset;
|
||||
gap: 4px;
|
||||
}
|
||||
.tabs-trigger[data-state="inactive"] {
|
||||
color: #a4b7bf;
|
||||
background: #55595d;
|
||||
}
|
||||
.tabs-trigger[data-state="inactive"]:hover {
|
||||
color: #6bbaff;
|
||||
background: #4c4f52;
|
||||
}
|
||||
.card-border {
|
||||
box-shadow: 0 0 10px -1px #0000000f;
|
||||
border: 1px solid #393939;
|
||||
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
|
||||
}
|
||||
a[aria-current="page"] {
|
||||
background-color: rgb(32, 148, 250);
|
||||
color: #000;
|
||||
}
|
||||
.card-header {
|
||||
@apply border-b-[#3f4042];
|
||||
}
|
||||
.form-section-title {
|
||||
@apply bg-[#5e7b9c] text-[#fff];
|
||||
}
|
||||
.gis-layer-panel {
|
||||
background: #111214;
|
||||
border-right-color: #2f3136;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.gis-layer-panel-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.gis-layer-panel hr {
|
||||
border-color: #2f3136;
|
||||
}
|
||||
.gis-layer-row {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.gis-layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.gis-layer-row span,
|
||||
.gis-layer-row label {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
.gis-layer-row input[type="checkbox"] {
|
||||
accent-color: #c026d3;
|
||||
}
|
||||
.gis-layer-action-button {
|
||||
background: #1a1c20;
|
||||
border-color: #3b82f6;
|
||||
color: #cfe3ff;
|
||||
}
|
||||
.gis-layer-action-button:hover {
|
||||
background: #23262b;
|
||||
}
|
||||
.gis-layer-setting {
|
||||
background: #111214;
|
||||
border-top: 1px solid #2f3136;
|
||||
}
|
||||
.gis-layer-toggle {
|
||||
background: #111214;
|
||||
border-color: #2f3136;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.gis-layer-toggle:hover {
|
||||
background: #1a1c20;
|
||||
}
|
||||
input[type="file"] {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #262626;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="blue"] {
|
||||
.card-border {
|
||||
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
|
||||
}
|
||||
.card-header {
|
||||
@apply border-b-[#3f4042];
|
||||
}
|
||||
.form-section-title {
|
||||
@apply bg-[#eaf3fb] text-[#647784];
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="green"] {
|
||||
.card-border {
|
||||
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
|
||||
}
|
||||
.card-header {
|
||||
@apply border-b-[#3f4042];
|
||||
}
|
||||
.form-section-title {
|
||||
@apply bg-[#ebf7f7] text-[#64908f];
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="red"] {
|
||||
.card-border {
|
||||
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
|
||||
}
|
||||
.card-header {
|
||||
@apply border-b-[#3f4042];
|
||||
}
|
||||
.form-section-title {
|
||||
@apply bg-[#f6f6f9] text-[#808080];
|
||||
}
|
||||
}
|
||||
}
|
||||
/* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */
|
||||
/* `I/I 분석 > 침입수 분석결과`, `RDII 분석결과`는 지도 popup 흐름으로 대체하고 레거시 라우트는 유지한 채 LNB에서만 숨긴다. */
|
||||
|
||||
@@ -141,6 +141,12 @@ export const windowPopupDefinitions = {
|
||||
width: 1080,
|
||||
height: 760,
|
||||
},
|
||||
agedPipeStatusByCtpvPopup: {
|
||||
key: "agedPipeStatusByCtpvPopup",
|
||||
title: "시도별 노후관로현황",
|
||||
width: 1160,
|
||||
height: 760,
|
||||
},
|
||||
fldngHstryPopup: {
|
||||
key: "fldngHstryPopup",
|
||||
title: "침수발생이력",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
|
||||
import type { WindowPopupRegistryItem } from "./model/types";
|
||||
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
|
||||
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
|
||||
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
|
||||
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
|
||||
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
|
||||
@@ -117,6 +118,10 @@ export const windowPopupRegistry = {
|
||||
...windowPopupDefinitions.floodPredictionMapPopup,
|
||||
component: FloodPredictionPlaceholderPopupContent,
|
||||
},
|
||||
agedPipeStatusByCtpvPopup: {
|
||||
...windowPopupDefinitions.agedPipeStatusByCtpvPopup,
|
||||
component: AgedPipeStatusByCtpvPopupContent,
|
||||
},
|
||||
fldngHstryPopup: {
|
||||
...windowPopupDefinitions.fldngHstryPopup,
|
||||
component: FldngHstryPopupContent,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import AgedPipeStatusByCtpvPopupPage from "@/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage";
|
||||
|
||||
export default function AgedPipeStatusByCtpvPopupContent() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AgedPipeStatusByCtpvPopupPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -585,10 +585,8 @@ export default React.memo(function GisTree({
|
||||
const isSinkholeRootTreeNode =
|
||||
String(node.id) === "SN_SINKHOLE" ||
|
||||
nodeLabel === "지반침하정보";
|
||||
const isFloodHistoryRootTreeNode = nodeLabel === "침수흔적도";
|
||||
const hideTreeLegendIcon =
|
||||
(isSinkholeRootTreeNode && !sinkholeLegendIconVisible) ||
|
||||
isFloodHistoryRootTreeNode;
|
||||
(isSinkholeRootTreeNode && !sinkholeLegendIconVisible);
|
||||
const showLayerActionButton = layerIconVisible?.(node);
|
||||
const showLayerActionIcon = depth > 0 && imageVisible;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user