통수능/준설관리

This commit is contained in:
2026-08-05 16:05:07 +09:00
parent 0f8b6cd6d0
commit 493acc9ebd
48 changed files with 2698 additions and 2123 deletions
+125
View File
@@ -0,0 +1,125 @@
type ExcelDownloadWithHeaderProps = {
data: JSON | JSON[];
fileName: string;
sheetNameArray: string[];
headerRange?: string;
headerMapping: Record<string, string>;
mergeRanges?: string[];
};
type ExcelRow = Record<string, unknown>;
// 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다.
const normalizeSheetName = (sheetName: string | undefined) => {
const normalized = (sheetName || "Sheet1").replace(/[\\/?*[\]:]/g, "_");
return normalized.slice(0, 31) || "Sheet1";
};
// 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다.
const getDisplayWidth = (value: unknown) =>
Array.from(String(value ?? "")).reduce(
(width, character) => width + (character.charCodeAt(0) > 255 ? 2 : 1),
0,
);
/**
* Turbopack이 지원하지 않는 xlsx-populate의 동적 require를 피하면서
* 기존 한글 헤더, 병합 범위와 XLSX 다운로드 계약을 유지한다.
*/
export const downloadExcelWithKoreanHeaders = async ({
data,
fileName,
sheetNameArray,
headerRange = "A1:V1",
headerMapping,
mergeRanges = [],
}: ExcelDownloadWithHeaderProps) => {
if (typeof window === "undefined") {
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
}
const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[];
if (dataRows.length === 0) {
throw new Error("데이터가 없습니다.");
}
// XLSX 모듈도 다운로드 버튼을 누를 때만 불러와 지도 화면의 최초 컴파일을 줄인다.
const XLSX = await import("xlsx");
const englishHeaders = Object.keys(dataRows[0] ?? {});
const koreanHeaders = englishHeaders.map(
(header) => headerMapping[header] || header,
);
const worksheetRows = [
koreanHeaders,
...dataRows.map((row) => englishHeaders.map((header) => row[header])),
];
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
const decodedHeaderRange = XLSX.utils.decode_range(headerRange);
for (
let rowIndex = decodedHeaderRange.s.r;
rowIndex <= decodedHeaderRange.e.r;
rowIndex += 1
) {
for (
let columnIndex = decodedHeaderRange.s.c;
columnIndex <= decodedHeaderRange.e.c;
columnIndex += 1
) {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: columnIndex,
});
const headerCell = worksheet[cellAddress] as
| (typeof worksheet[string] & { s?: Record<string, unknown> })
| undefined;
if (headerCell) {
headerCell.s = {
fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } },
font: { bold: true, sz: 13 },
alignment: {
horizontal: "center",
vertical: "center",
wrapText: true,
},
border: {
top: { style: "thin", color: { rgb: "FFFFFF" } },
bottom: { style: "thin", color: { rgb: "FFFFFF" } },
left: { style: "thin", color: { rgb: "FFFFFF" } },
right: { style: "thin", color: { rgb: "FFFFFF" } },
},
};
}
}
}
worksheet["!autofilter"] = { ref: headerRange };
worksheet["!merges"] = mergeRanges.map((range) =>
XLSX.utils.decode_range(range),
);
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
const sampledValues = dataRows
.slice(0, 200)
.map((row) => row[header])
.concat(koreanHeaders[columnIndex]);
const width = Math.max(...sampledValues.map(getDisplayWidth), 10);
return { wch: Math.min(width + 2, 40) };
});
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
worksheet,
normalizeSheetName(sheetNameArray[0]),
);
const normalizedFileName = fileName.toLowerCase().endsWith(".xlsx")
? fileName
: `${fileName}.xlsx`;
XLSX.writeFile(workbook, normalizedFileName, {
bookType: "xlsx",
compression: true,
cellStyles: true,
});
};