From 0b441494ba0a2334983c145bf6b1d13cc9586cc1 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 12 May 2026 16:29:12 +0900 Subject: [PATCH] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20-=20=EC=97=91=EC=85=80=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agedPipe/AgedPipePopupPage.tsx | 38 ++++++--- .../map/components/agedPipe/agedPipeExport.ts | 42 ++++++++++ .../map/components/agedPipe/agedPipeUtils.ts | 80 ++++++++++++++++++- apps/sn/app/(map-layout)/map/page.tsx | 38 ++++++--- apps/sn/app/components/lib/api/index.ts | 40 ++++++++++ 5 files changed, 216 insertions(+), 22 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index 3626054c7..d7fe9dc1f 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -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() { + {downloadProgressText ? ( +
+ {downloadProgressText} +
+ ) : null} +
) : null} + {agedPipeDownloadProgressText ? ( +
+ {agedPipeDownloadProgressText} +
+ ) : null} => { } }; +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 => { const res = await axios.post(`${BASE_URL}/${url}`, data, { headers: DEFAULT_HEADERS,