React | 엑셀 내보내기 (xlsx, xlsx-js-style)

샘샘·2024년 11월 6일
0

React

목록 보기
30/31

얼마 전 개발한 페이지에 데이터가 많아서 엑셀로 확인하고 싶다는 요구사항이 있었다


작업 단계 설정

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가 되면 위에서 만들어준 함수를 실행시켜줬다

라이브러리 덕분에 엑셀을 내보내는것 자체는 어렵지 않았지만 원하는 스타일대로 커스텀하는데에 시간이 꽤나 걸렸다

profile
회계팀 출신 FE개발자 👉콘테크 회사에서 웹개발을 하고 있습니다

0개의 댓글