노후관로 분석 - 엑셀 다운로드 기능 보완
This commit is contained in:
@@ -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 ?? "");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user