그리드 정렬기능

This commit is contained in:
2026-08-26 16:25:29 +09:00
parent ccfbe6ae32
commit e0cdba00be
61 changed files with 1777 additions and 590 deletions
+87 -59
View File
@@ -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`;
+48
View File
@@ -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;
}