얼마 전 개발한 페이지에 데이터가 많아서 엑셀로 확인하고 싶다는 요구사항이 있었다
1️⃣ 엑셀용 쿼리를 따로 요청해야 한다
2️⃣ 다운로드 핸들러에 쿼리를 요청한다
3️⃣ 쿼리 로딩 상태를 받아서 UI를 나타낸다
4️⃣ 엑셀 파일 내부의 헤더, 스타일을 설정한다
import * as XLSX from "xlsx-js-style";
먼저 XLSX
를 설치하고 import 해준다
처음엔 xlsx
를 사용했지만, 셀 스타일 때문에 다시 설치해줬다
xlsx-js-style
이 라이브러리도 어차피 xlsx를 fork해서 만들어진 것이기 때문에 작업을 새로 할 필요가 없었다!
const excelFileType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8";
const excelFileExtension = ".xlsx";
const excelFileName = "통합 문서";
기본적으로 설정해야 할 것들
파일 확장자와 저장될 파일의 이름을 선언한다
const [getExcelData, { loading: excelLoading }] = useGetExcelDataLazyQuery({
fetchPolicy: "network-only",
});
엑셀 데이터가 들어있는 graphQL
을 선언해주고 로딩 상태를 받는다
const excelDownload = (excelData: GetExcelDataType[]) => {
const ws = XLSX.utils.aoa_to_sheet(excelHeader);
// 헤더 스타일 적용
excelHeader.forEach((row, rowIndex) => {
row.forEach((_, colIndex) => {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: colIndex,
});
ws[cellAddress].s = {
font: { bold: true },
alignment: { horizontal: "center", vertical: "center" },
fill: { fgColor: { rgb: "F1F1F1" } },
border: {
top: { style: "thin", color: { rgb: "000000" } },
bottom: { style: "thin", color: { rgb: "000000" } },
left: { style: "thin", color: { rgb: "000000" } },
right: { style: "thin", color: { rgb: "000000" } },
},
};
});
});
// 데이터 추가
excelData.forEach((data) => {
const rowData = [
data.groupName,
data.floorName,
data.drawingName,
data.dueDate,
data.approvalStatus,
data.status,
data.SD500_10,
data.SD500_13,
data.SD500_16,
data.SD500_19,
data.SD500S_10,
data.SD500S_13,
data.SD600_13,
data.SD600_16,
data.SD600_19,
data.SD600_22,
data.SD600_25,
data.SD600S_22,
data.weightSum,
data.shippedValue,
data.notShippedValue,
];
const rowIndex = ws["!ref"]
? XLSX.utils.decode_range(ws["!ref"]).e.r + 1
: 0;
XLSX.utils.sheet_add_aoa(ws, [rowData], { origin: -1 });
// 각 셀에 스타일 적용
rowData.forEach((_, colIndex) => {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: colIndex,
});
ws[cellAddress].s = {
border: {
top: { style: "thin", color: { rgb: "000000" } },
bottom: { style: "thin", color: { rgb: "000000" } },
left: { style: "thin", color: { rgb: "000000" } },
right: { style: "thin", color: { rgb: "000000" } },
},
};
});
});
// 셀 범위 구하기
const range = XLSX.utils.decode_range(ws["!ref"] || "A1");
// 각 열의 합계를 계산하여 마지막 행에 추가 (3번째 셀부터)
const summaryRow = ["합계", "", "", "", "", ""];
for (let col = 6; col <= range.e.c; col++) {
let colSum = 0;
for (let row = 2; row <= range.e.r; row++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = ws[cellAddress];
if (cell && !isNaN(Number(cell.v))) {
colSum += Number(cell.v);
}
}
summaryRow.push(String(colSum));
}
XLSX.utils.sheet_add_aoa(ws, [summaryRow], { origin: -1 });
// 합계 행 스타일 적용 및 병합
const summaryRowIndex = range.e.r + 1;
for (let col = 0; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({
r: summaryRowIndex,
c: col,
});
ws[cellAddress].s = {
font: { bold: true },
fill: { fgColor: { rgb: "F1F5FE" } },
alignment: { horizontal: "center", vertical: "center" },
border: {
top: { style: "thin", color: { rgb: "000000" } },
bottom: { style: "thin", color: { rgb: "000000" } },
left: { style: "thin", color: { rgb: "000000" } },
right: { style: "thin", color: { rgb: "000000" } },
},
};
}
ws["!merges"] = [
...(ws["!merges"] || []),
{ s: { r: summaryRowIndex, c: 0 }, e: { r: summaryRowIndex, c: 5 } },
...excelMerge,
];
ws["!cols"] = [{ wpx: 100 }, { wpx: 100 }, { wpx: 200 }];
const wb = { Sheets: { data: ws }, SheetNames: ["data"] };
const excelBuffer = XLSX.write(wb, { bookType: "xlsx", type: "array" });
const excelFile = new Blob([excelBuffer], { type: excelFileType });
FileSaver.saveAs(excelFile, excelFileName + excelFileExtension);
};
이게 엑셀 파일로 내보내는 하나의 함수인데..
헤더와 푸터를 이런 고정 형식으로 내보내줘야 하기 때문에 하드코딩이 많아졌다
위 이미지를 보면 헤더에서 2개의 행이 병합되는 부분도 있기 때문에 2행을 만들어줬다
export const excelHeader = [
[
{ v: "빌딩명" },
{ v: "층이름" },
{ v: "도면명" },
{ v: "입고예정일" },
{ v: "승인현황" },
{ v: "상태" },
{ v: "SD500" },
{ v: "" },
{ v: "" },
{ v: "" },
{ v: "SD500S" },
{ v: "" },
{ v: "SD600" },
{ v: "" },
{ v: "" },
{ v: "" },
{ v: "" },
{ v: "SD600S" },
{ v: "합계" },
{ v: "출고 물량" },
{ v: "미출고 물량" },
],
[
{ v: "" },
{ v: "" },
{ v: "" },
{ v: "" },
{ v: "" },
{ v: "" },
{ v: "10" },
{ v: "13" },
{ v: "16" },
{ v: "19" },
{ v: "10" },
{ v: "13" },
{ v: "13" },
{ v: "16" },
{ v: "19" },
{ v: "22" },
{ v: "25" },
{ v: "22" },
{ v: "" },
{ v: "" },
{ v: "" },
],
];
병합되어지는 셀은 빈 문자열로 만들어줬다 (v: value)
그리고 파라미터로 받는 excelData
도 순서를 정해줘야 하기 때문에
excelData.forEach((data) => {
const rowData = [
data.groupName,
data.floorName,
data.drawingName,
data.dueDate,
data.approvalStatus,
data.status,
data.SD500_10,
data.SD500_13,
data.SD500_16,
data.SD500_19,
data.SD500S_10,
data.SD500S_13,
data.SD600_13,
data.SD600_16,
data.SD600_19,
data.SD600_22,
data.SD600_25,
data.SD600S_22,
data.weightSum,
data.shippedValue,
data.notShippedValue,
];
이렇게 하나씩 넣어줬다
헤더 스타일을 정하기 위해서 공식 문서를 확인했다
excelHeader.forEach((row, rowIndex) => {
row.forEach((_, colIndex) => {
const cellAddress = XLSX.utils.encode_cell({
r: rowIndex,
c: colIndex,
});
ws[cellAddress].s = {
font: { bold: true },
alignment: { horizontal: "center", vertical: "center" },
fill: { fgColor: { rgb: "F1F1F1" } },
border: {
top: { style: "thin", color: { rgb: "000000" } },
bottom: { style: "thin", color: { rgb: "000000" } },
left: { style: "thin", color: { rgb: "000000" } },
right: { style: "thin", color: { rgb: "000000" } },
},
};
});
});
배경색이 자꾸 적용이 안돼서 애먹었는데 알고보니 #
을 빼고 쓰면 되더라..! 🤦♀️
alignment
로 가운데 정렬 해주고 fill
로 배경색 채워주기
그리고 모든 셀에 선이 적용되어야 해서 헤더에도 border
를 설정해줬다
엑셀 푸터 부분에는 합계를 넣어줘야 하기 때문에 프론트에서 계산해서 출력하는걸로 했다
const summaryRow = ["합계", "", "", "", "", ""];
for (let col = 6; col <= range.e.c; col++) {
let colSum = 0;
for (let row = 2; row <= range.e.r; row++) {
const cellAddress = XLSX.utils.encode_cell({ r: row, c: col });
const cell = ws[cellAddress];
if (cell && !isNaN(Number(cell.v))) {
colSum += Number(cell.v);
}
}
summaryRow.push(String(colSum));
}
우선 문자열로 된 부분들을 병합해서 합계 텍스트를 나타내고
헤더가 있는 맨 위 2개의 행을 제외한 나머지 행들의 합을 구했다
이렇게 이것저것 다 설정해주고
const checkedDownloadExcel = async (checked: number[]) => {
await getExcelData({
variables: {
projectUuid: projectId as string,
where: {
id: { in: checked },
},
},
onCompleted: (data) => {
const result = getExcelDataType(data);
excelDownload(result);
},
onError: () => {
getSnackbar("다시 시도해주세요.", "error");
},
});
};
핸들러 내부에서 onCompleted
가 되면 위에서 만들어준 함수를 실행시켜줬다
라이브러리 덕분에 엑셀을 내보내는것 자체는 어렵지 않았지만 원하는 스타일대로 커스텀하는데에 시간이 꽤나 걸렸다