From 7fa45386670132d9afcde813eea1f8f963565f1d Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 11 Aug 2026 14:34:47 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EB=8F=84=EC=8B=9C=EC=B9=A8=EC=88=98?= =?UTF-8?q?=EC=98=88=EB=B0=A9=EC=82=AC=EC=97=85=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20header=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/cty-fldng-prvtn-biz-excel.ts | 311 ++++++++++++++++-- .../model/xlsx-populate-browser.d.ts | 26 ++ .../ui/CtyFldngPrvtnBizListPage.tsx | 6 +- 3 files changed, 311 insertions(+), 32 deletions(-) create mode 100644 apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts index d1ca1c51b..5a0b726af 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts @@ -1,4 +1,5 @@ import * as XLSX from "xlsx"; +import XlsxPopulate from "xlsx-populate/browser/xlsx-populate"; import type { CtyFldngDetail, @@ -369,8 +370,238 @@ const writeDetail = ( } }; -/** 현재 연도까지 업로드와 같은 열 위치를 가진 한 시트 xlsx를 생성한다. */ -export const downloadCtyFldngPrvtnExcel = ( +/** 헤더 배열의 지정 위치에 값을 기록한다. */ +const putHeader = ( + aoa: unknown[][], + row: number, + col: number, + value: string, +) => { + aoa[row]![col] = value; +}; + +/** 0기준 좌표를 SheetJS 병합 범위로 추가한다. */ +const addMerge = ( + merges: XLSX.Range[], + startRow: number, + startCol: number, + endRow: number, + endCol: number, +) => { + merges.push({ s: { r: startRow, c: startCol }, e: { r: endRow, c: endCol } }); +}; + +/** 공통 3열 우수관로 헤더와 세로 병합을 구성한다. */ +const writePipeHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, +) => { + putHeader(aoa, 9, start, "우수관로(km)"); + addMerge(merges, 9, start, 9, start + 2); + ["합계", "신설", "개량"].forEach((label, index) => { + putHeader(aoa, 10, start + index, label); + addMerge(merges, 10, start + index, 11, start + index); + }); +}; + +/** 펌프장·유수지 세부번호 헤더를 병합 구조로 구성한다. */ +const writePumpHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, + pumpCount: number, +) => { + const pumpWidth = pumpCount + 1; + putHeader(aoa, 9, start, "빗물펌프장(㎥/분, ㎥)"); + addMerge(merges, 9, start, 9, start + pumpWidth + 3); + putHeader(aoa, 10, start, "펌프장"); + addMerge(merges, 10, start, 10, start + pumpWidth - 1); + putHeader(aoa, 10, start + pumpWidth, "유수지"); + addMerge(merges, 10, start + pumpWidth, 10, start + pumpWidth + 3); + [ + "합계", + ...Array.from({ length: pumpCount }, (_, index) => `#${index + 1}`), + ].forEach((label, index) => putHeader(aoa, 11, start + index, label)); + ["합계", "#1", "#2", "#3"].forEach((label, index) => + putHeader(aoa, 11, start + pumpWidth + index, label), + ); +}; + +/** 저류시설 합계·번호 헤더를 2개 행 병합 구조로 구성한다. */ +const writeStorageHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, + itemCount: number, +) => { + putHeader(aoa, 9, start, "저류시설(㎥)"); + addMerge(merges, 9, start, 10, start + itemCount); + [ + "합계", + ...Array.from({ length: itemCount }, (_, index) => `#${index + 1}`), + ].forEach((label, index) => putHeader(aoa, 11, start + index, label)); +}; + +/** 대심도터널 4개 항목 헤더를 구성한다. */ +const writeTunnelHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, +) => { + putHeader(aoa, 9, start, "대심도터널"); + addMerge(merges, 9, start, 9, start + 3); + ["직경(m)", "길이(km)", "저수용량(㎥)", "굴착심도(m)"].forEach( + (label, index) => { + putHeader(aoa, 10, start + index, label); + addMerge(merges, 10, start + index, 11, start + index); + }, + ); +}; + +/** EM/MP/DD/CW 구역별 5단 병합 헤더를 원본 위치 계약에 맞게 구성한다. */ +const writeDetailSectionHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, + code: (typeof DETAIL_CODES)[number], +) => { + const end = start + DETAIL_WIDTHS[code] - 1; + putHeader(aoa, 7, start, DETAIL_TITLES[code]); + addMerge(merges, 7, start, 7, end); + putHeader(aoa, 8, start, "면적\n(ha)"); + addMerge(merges, 8, start, 11, start); + putHeader(aoa, 8, start + 1, "설계기준"); + addMerge(merges, 8, start + 1, 8, start + 3); + ["설계빈도\n(년)", "강우강도\n(mm/hr)", "방재성능목표\n(mm/hr)"].forEach( + (label, index) => { + putHeader(aoa, 9, start + 1 + index, label); + addMerge(merges, 9, start + 1 + index, 11, start + 1 + index); + }, + ); + + const contentEnd = + code === "EM" + ? start + 25 + : code === "MP" + ? start + 25 + : code === "DD" + ? start + 23 + : start + 25; + putHeader(aoa, 8, start + 4, "사업내용 ※ 개소별 용량 기재"); + addMerge(merges, 8, start + 4, 8, contentEnd); + writePipeHeader(aoa, merges, start + 4); + + const pumpCount = PUMP_COUNTS[code]; + writePumpHeader(aoa, merges, start + 7, pumpCount); + const storageStart = start + 7 + pumpCount + 5; + writeStorageHeader(aoa, merges, storageStart, IMPD_COUNTS[code]); + const tunnelStart = storageStart + IMPD_COUNTS[code] + 1; + writeTunnelHeader(aoa, merges, tunnelStart); + + let next = tunnelStart + 4; + if (code !== "EM") { + ["빗물받이\n(개소)", "맨홀추락방지\n(개소)"].forEach((label) => { + putHeader(aoa, 9, next, label); + addMerge(merges, 9, next, 11, next); + next += 1; + }); + } + + if (code === "EM") { + putHeader(aoa, 8, next, "사업비\n(백만원)"); + addMerge(merges, 8, next, 11, next); + return; + } + + putHeader(aoa, 8, next, "사업비(백만원)"); + addMerge(merges, 8, next, 8, next + 2); + ["총사업비", "국고", "지방비"].forEach((label, index) => { + putHeader(aoa, 9, next + index, label); + addMerge(merges, 9, next + index, 11, next + index); + }); + next += 3; + + if (code === "CW") { + [ + "착공(예정)일", + "준공(예정)일", + "비고(출처)\nex. 사업추진현황, 운영관리현황, 승인 등", + ].forEach((label, index) => { + putHeader(aoa, 8, next + index, label); + addMerge(merges, 8, next + index, 11, next + index); + }); + return; + } + + putHeader(aoa, 8, next, code === "MP" ? "수립기간" : "설계기간"); + addMerge(merges, 8, next, 8, next + 1); + ["착수", "준공"].forEach((label, index) => { + putHeader(aoa, 9, next + index, label); + addMerge(merges, 9, next + index, 11, next + index); + }); + next += 2; + putHeader(aoa, 8, next, code === "MP" ? "정비대책 승인" : "승인"); + addMerge(merges, 8, next, 8, end); + const approvals = + code === "MP" ? ["최초", "1차 변경", "2차 변경"] : ["최초", "변경"]; + approvals.forEach((label, index) => { + putHeader(aoa, 9, next + index, label); + addMerge(merges, 9, next + index, 11, next + index); + }); +}; + +/** XlsxPopulate로 병합 헤더 색·테두리를 적용하고 브라우저 다운로드를 시작한다. */ +const saveStyledWorkbook = async ( + workbook: XLSX.WorkBook, + filename: string, + starts: Record<(typeof DETAIL_CODES)[number], number>, + last: number, +) => { + const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" }); + const styledWorkbook = await XlsxPopulate.fromDataAsync(data); + const styledSheet = styledWorkbook.sheet(SHEET_NAME); + const lastColumn = XLSX.utils.encode_col(last); + const commonHeaderStyle = { + bold: true, + fontFamily: "맑은 고딕", + fontSize: 11, + horizontalAlignment: "center", + verticalAlignment: "center", + wrapText: true, + border: true, + }; + styledSheet.range(`B8:${lastColumn}12`).style(commonHeaderStyle); + styledSheet + .range(`B8:${XLSX.utils.encode_col(starts.EM - 1)}12`) + .style("fill", "B4C6E7"); + DETAIL_CODES.forEach((code) => { + const end = starts[code] + DETAIL_WIDTHS[code] - 1; + styledSheet + .range( + `${XLSX.utils.encode_col(starts[code])}8:${XLSX.utils.encode_col(end)}12`, + ) + .style("fill", code === "EM" || code === "DD" ? "EDFEE7" : "E7F4FE"); + }); + styledSheet + .range( + `${XLSX.utils.encode_col(starts.CW + DETAIL_WIDTHS.CW)}8:${lastColumn}12`, + ) + .style("fill", "B4C6E7"); + [8, 9, 10, 11, 12].forEach((row) => + styledSheet.row(row).height(row === 8 ? 30 : 24), + ); + const blob = await styledWorkbook.outputAsync(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +/** 현재 연도까지 원본과 같은 병합 헤더 위치·구분색을 가진 한 시트 xlsx를 생성한다. */ +export const downloadCtyFldngPrvtnExcel = async ( businesses: CtyFldngPrvtnBiz[], templateOnly = false, ) => { @@ -393,41 +624,58 @@ export const downloadCtyFldngPrvtnExcel = ( const aoa: unknown[][] = Array.from({ length: DATA_START_ROW }, () => Array(last + 1).fill(""), ); + const merges: XLSX.Range[] = []; aoa[0]![1] = "하수도정비중점관리지역 도시침수대응사업 현황"; - const basic = [ - "순번", - "단계", - "시도", - "시군구", - "환경청", - "지정년도", - "지정일", - "지정면적(ha)", - "유형", - "배수구역", - "배수분구", - "위치", - "사업명", - "사업코드", - "추진현황", - "사업추진 현황", - "총사업비", - "국고", - "지방비", + const verticalBasicHeaders: Array<[number, string]> = [ + [1, "순번"], + [2, "단계"], + [3, "시도"], + [4, "시군구"], + [5, "환경청"], + [6, "지정년도"], + [7, "지정일"], + [8, "지정면적\n(ha)"], + [9, "유형"], + [13, "사업명"], + [14, "사업코드"], + [15, "추진현황\n(수립중/수립완료/설계중/설계완료/\n공사중/완료)"], + [16, "사업추진\n현황"], ]; - basic.forEach((v, i) => { - aoa[11]![i + 1] = v; + verticalBasicHeaders.forEach(([col, label]) => { + putHeader(aoa, 7, col, label); + addMerge(merges, 7, col, 11, col); + }); + putHeader(aoa, 7, 10, "범위"); + addMerge(merges, 7, 10, 7, 12); + ["배수구역", "배수분구", "위치"].forEach((label, index) => { + putHeader(aoa, 8, 10 + index, label); + addMerge(merges, 8, 10 + index, 11, 10 + index); + }); + putHeader(aoa, 7, 17, "총사업비(백만원)"); + addMerge(merges, 7, 17, 8, 19); + ["총사업비", "국고", "지방비"].forEach((label, index) => { + putHeader(aoa, 9, 17 + index, label); + addMerge(merges, 9, 17 + index, 11, 17 + index); }); aoa[7]![20] = "결산 현황(백만원)"; + addMerge(merges, 7, 20, 8, starts.EM - 1); + putHeader(aoa, 9, 20, "총계"); + addMerge(merges, 9, 20, 11, 20); years.forEach((year, i) => { - aoa[11]![21 + i] = year; + putHeader(aoa, 9, 21 + i, `${year.slice(2)}년도`); + addMerge(merges, 9, 21 + i, 11, 21 + i); }); DETAIL_CODES.forEach((code) => { - aoa[7]![starts[code]] = DETAIL_TITLES[code]; + writeDetailSectionHeader(aoa, merges, starts[code], code); }); - aoa[11]![last - 2] = "담당자 이름"; - aoa[11]![last - 1] = "담당자 연락처"; - aoa[11]![last] = "비고(특이사항, 사업 지연 사유 등)"; + putHeader(aoa, 7, last - 2, "담당자"); + addMerge(merges, 7, last - 2, 8, last - 1); + ["이름", "연락처"].forEach((label, index) => { + putHeader(aoa, 9, last - 2 + index, label); + addMerge(merges, 9, last - 2 + index, 11, last - 2 + index); + }); + putHeader(aoa, 7, last, "비고\n(특이사항, 사업 지연 사유 등)"); + addMerge(merges, 7, last, 11, last); if (!templateOnly) businesses.forEach((b, index) => { const row = Array(last + 1).fill(""); @@ -472,11 +720,14 @@ export const downloadCtyFldngPrvtnExcel = ( aoa.push(row); }); const sheet = XLSX.utils.aoa_to_sheet(aoa); + sheet["!merges"] = merges; sheet["!cols"] = Array.from({ length: last + 1 }, () => ({ wch: 14 })); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, sheet, SHEET_NAME); - XLSX.writeFile( + await saveStyledWorkbook( workbook, templateOnly ? "도시침수예방사업_업로드양식.xlsx" : DOWNLOAD_FILENAME, + starts, + last, ); }; diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts new file mode 100644 index 000000000..2a600af27 --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts @@ -0,0 +1,26 @@ +declare module "xlsx-populate/browser/xlsx-populate" { + type Range = { + style(styles: Record): Range; + style(name: string, value: unknown): Range; + }; + + type Row = { + height(value: number): Row; + }; + + type Sheet = { + range(address: string): Range; + row(index: number): Row; + }; + + type Workbook = { + sheet(name: string): Sheet; + outputAsync(): Promise; + }; + + const XlsxPopulate: { + fromDataAsync(data: ArrayBuffer | Uint8Array): Promise; + }; + + export default XlsxPopulate; +} diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx index c79b17bf0..a91daaa51 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx @@ -353,7 +353,9 @@ export function CtyFldngPrvtnBizListPage() { Object.entries(applied).forEach(([k, v]) => { if (v.trim()) query.set(k, v.trim()); }); - downloadCtyFldngPrvtnExcel(await getAllCtyFldngPrvtnBusinesses(query)); + await downloadCtyFldngPrvtnExcel( + await getAllCtyFldngPrvtnBusinesses(query), + ); } catch (e) { setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다."); } finally { @@ -486,7 +488,7 @@ export function CtyFldngPrvtnBizListPage() {