노후관로 분석 - 엑셀 다운로드 기능 보완

This commit is contained in:
kiminchan
2026-05-12 16:29:12 +09:00
parent 446bd83210
commit 0b441494ba
5 changed files with 216 additions and 22 deletions
@@ -3,13 +3,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { UiTable } from "@common/business_components/ui";
import { getData } from "@/components/lib/api";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_LIST_HEADLIST,
AGED_PIPE_PAGE_SIZE,
@@ -20,6 +19,7 @@ import {
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
import { downloadAgedPipeCsv } from "./agedPipeExport";
const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
@@ -40,6 +40,7 @@ export default function AgedPipePopupPage() {
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [downloadProgressText, setDownloadProgressText] = useState("");
const sggOptions = useMemo(
() =>
@@ -156,17 +157,28 @@ export default function AgedPipePopupPage() {
try {
setIsLoading(true);
const response = await fetchRows(1, filters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 현황"],
headerRange: "A1:J1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
setDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(filters, (job) => {
if (job.status === "QUEUED") {
setDownloadProgressText("CSV export 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
setDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
setDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
@@ -202,6 +214,12 @@ export default function AgedPipePopupPage() {
</div>
</header>
{downloadProgressText ? (
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
{downloadProgressText}
</div>
) : null}
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[160px]">
@@ -0,0 +1,42 @@
"use client";
import { postBlobData } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import {
AGED_PIPE_EXCEL_FIELDS,
normalizeAgedPipeFilters,
type AgedPipeFilters,
} from "./agedPipeUtils";
type AgedPipeExportProgress = {
fileName?: string;
progressPercent?: number;
status: "QUEUED" | "RUNNING" | "COMPLETED";
};
/** 노후관로 CSV 다운로드를 요청한다. */
export const downloadAgedPipeCsv = async (
filters: AgedPipeFilters,
onProgress?: (progress: AgedPipeExportProgress) => void,
) => {
const normalizedFilters = normalizeAgedPipeFilters(filters);
onProgress?.({ status: "RUNNING", progressPercent: 0 });
const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", {
elapsedYears: normalizedFilters.elapsedYears,
ctpvCd: normalizedFilters.ctpvCd,
sggCd: normalizedFilters.sggCd,
format: "csv",
columns: [...AGED_PIPE_EXCEL_FIELDS],
});
const resolvedFileName = fileName || "노후관로_현황.csv";
fileDownload(blob, resolvedFileName);
onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 });
return {
status: "COMPLETED" as const,
fileName: resolvedFileName,
};
};
@@ -63,8 +63,61 @@ export const AGED_PIPE_LIST_HEADLIST = [
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
];
export const AGED_PIPE_EXCEL_FIELDS = [
"ftreSmbolCd",
"fcltMngNo",
"pbadmsEmdCd",
"mapshtNo",
"mngInstCd",
"instlYmd",
"usgCd",
"mqltCd",
"fcltyShpCd",
"pdmtVl",
"wdthLen",
"vrtcLen",
"prlgVl",
"tmptDpth",
"scendDpth",
"avgGrdn",
"trflnCnt",
"exclnMltvArea",
"drtwMltvArea",
"rnyssnFvlcVl",
"clarFvlcVl",
"tmptMhlMngNo",
"scendMhlMngNo",
"instMngNo",
"fid",
"lyrTypeNm",
"swtrmtFcltCd",
"elapsedYears",
"ctpvNm",
"sggNm",
] as const;
export const AGED_PIPE_EXCEL_HEADER = {
rowNumber: "NO",
ftreSmbolCd: "지형지물부호코드",
pbadmsEmdCd: "행정읍면동코드",
mapshtNo: "도엽번호",
mngInstCd: "관리기관코드",
wdthLen: "가로길이",
vrtcLen: "세로길이",
tmptDpth: "시점깊이",
scendDpth: "종점깊이",
avgGrdn: "평균구배",
trflnCnt: "차선수",
exclnMltvArea: "우수배수면적",
drtwMltvArea: "오수배수면적",
rnyssnFvlcVl: "우기유속값",
clarFvlcVl: "맑음유속값",
tmptMhlMngNo: "시점맨홀관리번호",
scendMhlMngNo: "종점맨홀관리번호",
instMngNo: "기관관리번호",
fid: "FID",
lyrTypeNm: "레이어타입명",
swtrmtFcltCd: "시설코드",
fcltyShpCd: "시설물형태코드",
fcltMngNo: "시설관리번호",
instlYmd: "설치일자",
pdmtVl: "관경(mm)",
@@ -76,6 +129,21 @@ export const AGED_PIPE_EXCEL_HEADER = {
sggNm: "시군구",
};
const getExcelColumnLetter = (columnIndex: number) => {
let current = columnIndex;
let result = "";
while (current > 0) {
const remainder = (current - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
current = Math.floor((current - 1) / 26);
}
return result || "A";
};
export const AGED_PIPE_EXCEL_HEADER_RANGE = `A1:${getExcelColumnLetter(AGED_PIPE_EXCEL_FIELDS.length)}1`;
const pad2 = (value: number) => String(value).padStart(2, "0");
const normalizeYmd = (value: unknown) =>
@@ -232,6 +300,16 @@ export const toDisplayPipeRow = (
};
};
export const toAgedPipeExcelRow = (row: AgedPipeRow) => {
const exportRow: Record<string, unknown> = {};
AGED_PIPE_EXCEL_FIELDS.forEach((field) => {
exportRow[field] = row[field] ?? "-";
});
return exportRow;
};
export const isAgedPipeLayerNode = (node: any) => {
const data = node?.data ?? node;
const layerName = String(data?.lyrNm ?? node?.text ?? "");
+27 -11
View File
@@ -36,11 +36,9 @@ import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
import { Button } from "@common/components";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils";
import {
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_MIN_ZOOM,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_PAGE_SIZE,
@@ -54,6 +52,7 @@ import {
type AgedPipeRow,
AGED_PIPE_DEFAULT_FILTERS,
} from "./components/agedPipe/agedPipeUtils";
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE } from "./components/pipeAnalysis/pipeAnalysisUtils";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
@@ -528,6 +527,7 @@ const Map = () => {
const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0);
const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1);
const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false);
const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState("");
const { ctpv, sgg } = useStore();
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
@@ -1018,21 +1018,32 @@ const Map = () => {
try {
setIsAgedPipeListLoading(true);
const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 현황"],
headerRange: "A1:J1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => {
if (job.status === "QUEUED") {
setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setAgedPipeDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
setAgedPipeDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsAgedPipeListLoading(false);
}
}, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]);
}, [agedPipeTotalCount, appliedAgedPipeFilters]);
useEffect(() => {
const handleAgedPipePopupMessage = (event: MessageEvent) => {
@@ -1230,6 +1241,11 @@ const Map = () => {
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
{agedPipeDownloadProgressText ? (
<div className="absolute left-4 top-20 z-[60] max-w-[520px] rounded border border-emerald-200 bg-white px-4 py-3 text-sm text-emerald-700 shadow">
{agedPipeDownloadProgressText}
</div>
) : null}
<AnalysisPopupMenu onOpenPopup={openAnalysisPopup} />
<Gis
treeData={treeData}
+40
View File
@@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise<any> => {
}
};
const parseDownloadFileName = (contentDisposition: string | null) => {
if (!contentDisposition) return undefined;
const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1].trim());
}
const plainMatch = contentDisposition.match(/filename\s*=\s*"?([^\";]+)"?/i);
return plainMatch?.[1]?.trim();
};
export const postBlobData = async (
url: string,
data: object,
): Promise<{ blob: Blob; fileName?: string }> => {
const res = await fetch(resolveApiUrl(url), {
method: "POST",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`,
);
}
return {
blob: await res.blob(),
fileName: parseDownloadFileName(res.headers.get("content-disposition")),
};
};
export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
const res = await axios.post(`${BASE_URL}/${url}`, data, {
headers: DEFAULT_HEADERS,