sn: 도시침수예방사업 다운로드 header 수정

This commit is contained in:
2026-08-11 14:34:47 +09:00
parent 91e11a7857
commit 7fa4538667
3 changed files with 311 additions and 32 deletions
@@ -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,
);
};
@@ -0,0 +1,26 @@
declare module "xlsx-populate/browser/xlsx-populate" {
type Range = {
style(styles: Record<string, unknown>): 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<Blob>;
};
const XlsxPopulate: {
fromDataAsync(data: ArrayBuffer | Uint8Array): Promise<Workbook>;
};
export default XlsxPopulate;
}
@@ -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() {
<Button
className={ACTION}
size="sm"
onClick={() => downloadCtyFldngPrvtnExcel([], true)}
onClick={() => void downloadCtyFldngPrvtnExcel([], true)}
>
양식받기
</Button>