그리드 정렬기능
This commit is contained in:
@@ -8,6 +8,7 @@ type ExcelDownloadWithHeaderProps = {
|
||||
};
|
||||
|
||||
type ExcelRow = Record<string, unknown>;
|
||||
const EXCEL_DATA_ROWS_PER_SHEET = 500_000;
|
||||
|
||||
// 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다.
|
||||
const normalizeSheetName = (sheetName: string | undefined) => {
|
||||
@@ -15,6 +16,18 @@ const normalizeSheetName = (sheetName: string | undefined) => {
|
||||
return normalized.slice(0, 31) || "Sheet1";
|
||||
};
|
||||
|
||||
/** 다중 시트일 때 접미사가 잘리지 않도록 시트명 길이를 함께 조정한다. */
|
||||
const buildSheetName = (
|
||||
baseName: string | undefined,
|
||||
sheetIndex: number,
|
||||
sheetCount: number,
|
||||
) => {
|
||||
const normalizedBase = normalizeSheetName(baseName);
|
||||
if (sheetCount === 1) return normalizedBase;
|
||||
const suffix = `_${sheetIndex + 1}`;
|
||||
return `${normalizedBase.slice(0, 31 - suffix.length)}${suffix}`;
|
||||
};
|
||||
|
||||
// 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다.
|
||||
const getDisplayWidth = (value: unknown) =>
|
||||
Array.from(String(value ?? "")).reduce(
|
||||
@@ -38,7 +51,9 @@ export const downloadExcelWithKoreanHeaders = async ({
|
||||
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
|
||||
}
|
||||
|
||||
const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[];
|
||||
const dataRows = (Array.isArray(data)
|
||||
? data
|
||||
: [data]) as unknown as ExcelRow[];
|
||||
if (dataRows.length === 0) {
|
||||
throw new Error("데이터가 없습니다.");
|
||||
}
|
||||
@@ -49,71 +64,84 @@ export const downloadExcelWithKoreanHeaders = async ({
|
||||
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
|
||||
) {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheetCount = Math.max(
|
||||
1,
|
||||
Math.ceil(dataRows.length / EXCEL_DATA_ROWS_PER_SHEET),
|
||||
);
|
||||
|
||||
for (let sheetIndex = 0; sheetIndex < sheetCount; sheetIndex += 1) {
|
||||
const sheetRows = dataRows.slice(
|
||||
sheetIndex * EXCEL_DATA_ROWS_PER_SHEET,
|
||||
(sheetIndex + 1) * EXCEL_DATA_ROWS_PER_SHEET,
|
||||
);
|
||||
const worksheetRows = [
|
||||
koreanHeaders,
|
||||
...sheetRows.map((row) => englishHeaders.map((header) => row[header])),
|
||||
];
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows);
|
||||
|
||||
// SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다.
|
||||
for (
|
||||
let columnIndex = decodedHeaderRange.s.c;
|
||||
columnIndex <= decodedHeaderRange.e.c;
|
||||
columnIndex += 1
|
||||
let rowIndex = decodedHeaderRange.s.r;
|
||||
rowIndex <= decodedHeaderRange.e.r;
|
||||
rowIndex += 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" } },
|
||||
},
|
||||
};
|
||||
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 };
|
||||
if (sheetCount === 1) {
|
||||
worksheet["!merges"] = mergeRanges.map((range) =>
|
||||
XLSX.utils.decode_range(range),
|
||||
);
|
||||
}
|
||||
worksheet["!cols"] = englishHeaders.map((header, columnIndex) => {
|
||||
const sampledValues = sheetRows
|
||||
.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) };
|
||||
});
|
||||
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
worksheet,
|
||||
buildSheetName(sheetNameArray[0], sheetIndex, sheetCount),
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
type FetchAllPagesOptions<TResponse, TItem> = {
|
||||
fetchPage: (page: number, size: number) => Promise<TResponse>;
|
||||
getItems: (response: TResponse) => TItem[];
|
||||
getTotal: (response: TResponse) => number;
|
||||
batchSize?: number;
|
||||
};
|
||||
|
||||
export type FetchAllPagesResult<TItem> = {
|
||||
items: TItem[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 2000;
|
||||
const MAX_BATCH_COUNT = 10000;
|
||||
|
||||
/**
|
||||
* 서버의 Pageable 최대 크기에 의존하지 않고 모든 페이지를 순회한다.
|
||||
* 화면 전체건수와 Excel 데이터 행 수가 달라지는 문제를 공통으로 방지한다.
|
||||
*/
|
||||
export const fetchAllPages = async <TResponse, TItem>({
|
||||
fetchPage,
|
||||
getItems,
|
||||
getTotal,
|
||||
batchSize = DEFAULT_BATCH_SIZE,
|
||||
}: FetchAllPagesOptions<TResponse, TItem>): Promise<
|
||||
FetchAllPagesResult<TItem>
|
||||
> => {
|
||||
const normalizedBatchSize = Math.max(1, Math.trunc(batchSize));
|
||||
const items: TItem[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (let page = 0; page < MAX_BATCH_COUNT; page += 1) {
|
||||
const response = await fetchPage(page, normalizedBatchSize);
|
||||
const pageItems = getItems(response);
|
||||
if (page === 0) total = Math.max(0, Number(getTotal(response) ?? 0));
|
||||
items.push(...pageItems);
|
||||
|
||||
if (pageItems.length === 0 || items.length >= total) break;
|
||||
}
|
||||
|
||||
if (items.length !== total) {
|
||||
throw new Error(
|
||||
`전체 ${total.toLocaleString("ko-KR")}건 중 ${items.length.toLocaleString("ko-KR")}건만 조회되어 Excel 생성을 중단했습니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { items, total };
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { setBusinessUserMessageHandler } from "@common/business_components";
|
||||
|
||||
import { useAlertStore } from "../lib/use-alert-store";
|
||||
|
||||
/** 공통 비즈니스 컴포넌트의 사용자 알림을 SN 공통 메시지 다이얼로그로 연결한다. */
|
||||
export function BusinessUserMessageBridge() {
|
||||
const setMessage = useAlertStore((state) => state.setMessage);
|
||||
|
||||
useEffect(
|
||||
() => setBusinessUserMessageHandler((message) => setMessage(message)),
|
||||
[setMessage],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user