|
|
@@ -0,0 +1,200 @@
|
|
|
+import React, {useEffect, useState} from "react";
|
|
|
+import * as XLSX from "xlsx";
|
|
|
+import Tabs, {TabItem} from "@/components/common/Tabs";
|
|
|
+
|
|
|
+// 📁 엑셀 파일 가져오기
|
|
|
+const fetchExcelFile = async (url: string): Promise<ArrayBuffer> => {
|
|
|
+ const response = await fetch(encodeURI(url));
|
|
|
+ if (!response.ok) throw new Error("엑셀 파일 로드 실패");
|
|
|
+ return await response.arrayBuffer();
|
|
|
+};
|
|
|
+
|
|
|
+// 📁 워크북 파싱
|
|
|
+const parseWorkbook = (buffer: ArrayBuffer): XLSX.WorkBook => {
|
|
|
+ return XLSX.read(buffer, {type: "array", cellStyles: true});
|
|
|
+};
|
|
|
+
|
|
|
+// 🎨 강조 셀 판단
|
|
|
+const isHighlightedCell = (cell: XLSX.CellObject | undefined): boolean => {
|
|
|
+ const fill = (cell as any)?.s?.fill;
|
|
|
+ const fg = fill?.fgColor?.rgb || fill?.bgColor?.rgb;
|
|
|
+ return fg?.toLowerCase()?.includes("ffff00"); // 노란색
|
|
|
+};
|
|
|
+
|
|
|
+// 📄 시트 → JSON 변환 (강조 포함)
|
|
|
+const convertSheetToJson = (sheet: XLSX.WorkSheet): any[] => {
|
|
|
+ const range = XLSX.utils.decode_range(sheet["!ref"]!);
|
|
|
+ const rows: any[] = [];
|
|
|
+
|
|
|
+ let headerRowIndex = -1;
|
|
|
+ let headers: string[] = [];
|
|
|
+
|
|
|
+ // 실제 헤더 행 탐색
|
|
|
+ for (let R = range.s.r; R <= range.e.r; ++R) {
|
|
|
+ const rowValues: string[] = [];
|
|
|
+
|
|
|
+ for (let C = range.s.c; C <= range.e.c; ++C) {
|
|
|
+ const cellAddress = XLSX.utils.encode_cell({r: R, c: C});
|
|
|
+ const cell = sheet[cellAddress];
|
|
|
+ const value = cell?.v?.toString().trim() || "";
|
|
|
+
|
|
|
+ rowValues.push(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ // '발주처' 같은 주요 컬럼이 있으면 헤더로 판단
|
|
|
+ if (rowValues.some((v) => ["발주처", "공사명", "계약금액", "계약일"].includes(v))) {
|
|
|
+ // 헤더 처리 시 공백 제거
|
|
|
+ headers = rowValues.map((v) => v.replace(/\s+/g, ""));
|
|
|
+ headerRowIndex = R;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (headerRowIndex === -1) return []; // 헤더 못 찾은 경우
|
|
|
+
|
|
|
+ // 본문 데이터 파싱
|
|
|
+ for (let R = headerRowIndex + 1; R <= range.e.r; ++R) {
|
|
|
+ const row: any = {};
|
|
|
+ let hasValue = false;
|
|
|
+ let highlight = false;
|
|
|
+
|
|
|
+ for (let C = range.s.c; C <= range.e.c; ++C) {
|
|
|
+ const cellAddress = XLSX.utils.encode_cell({r: R, c: C});
|
|
|
+ const cell = sheet[cellAddress];
|
|
|
+ const value = cell?.v ?? "";
|
|
|
+ const key = headers[C];
|
|
|
+
|
|
|
+ if (key) {
|
|
|
+ row[key] = value;
|
|
|
+ if (value !== "") hasValue = true;
|
|
|
+ if (isHighlightedCell(cell)) highlight = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (hasValue) {
|
|
|
+ row._highlight = highlight;
|
|
|
+ rows.push(row);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return rows;
|
|
|
+};
|
|
|
+
|
|
|
+// 📦 워크북 → 연도별 데이터 구조로 변환
|
|
|
+const extractDataFromWorkbook = (workbook: XLSX.WorkBook): Record<string, any[]> => {
|
|
|
+ const result: Record<string, any[]> = {};
|
|
|
+
|
|
|
+ workbook.SheetNames.forEach((sheetName) => {
|
|
|
+ const year = sheetName.replace("공사계약현황", "").trim();
|
|
|
+ const sheet = workbook.Sheets[sheetName];
|
|
|
+ result[year] = convertSheetToJson(sheet);
|
|
|
+ });
|
|
|
+
|
|
|
+ return result;
|
|
|
+};
|
|
|
+
|
|
|
+const Sheet: React.FC = () => {
|
|
|
+ const [dataByYear, setDataByYear] = useState<Record<string, any[]>>({});
|
|
|
+ const [activeTab, setActiveTab] = useState<string>("");
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const loadExcelData = async () => {
|
|
|
+ try {
|
|
|
+ const buffer = await fetchExcelFile("/공사계약.xlsx");
|
|
|
+ const workbook = parseWorkbook(buffer);
|
|
|
+ const parsedData = extractDataFromWorkbook(workbook);
|
|
|
+ setDataByYear(parsedData);
|
|
|
+ console.log(parsedData);
|
|
|
+ setActiveTab(Object.keys(parsedData)[0]);
|
|
|
+ } catch (err) {
|
|
|
+ console.error("❌ 엑셀 데이터 처리 중 오류:", err);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ loadExcelData();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const tabItems: TabItem[] = Object.keys(dataByYear)
|
|
|
+ .map((key) => {
|
|
|
+ const year = key.match(/\d{4}/)?.[0] ?? key; // 4자리 숫자만 추출
|
|
|
+ return {
|
|
|
+ id: year,
|
|
|
+ label: year,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ const filteredRows = dataByYear[activeTab]
|
|
|
+ ?.filter((row) => !String(row["공사명"] || "")
|
|
|
+ .replace(/\s/g, "")
|
|
|
+ .includes("소계")
|
|
|
+ ) ?? [];
|
|
|
+
|
|
|
+ const totalAmount = filteredRows.reduce((sum, row) => {
|
|
|
+ const value = Number(row["계약금액"]);
|
|
|
+ return sum + (isNaN(value) ? 0 : value);
|
|
|
+ }, 0);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="max-w-7xl mx-auto px-4 py-10">
|
|
|
+ {activeTab && (
|
|
|
+ <Tabs items={tabItems} activeTab={activeTab} onTabChange={setActiveTab}>
|
|
|
+ <div className="text-center mb-6">
|
|
|
+ <div className="inline-block font-bold underline text-xl tracking-widest">
|
|
|
+ 공 사 계 약 현 황 ({activeTab})
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="overflow-y-scroll max-h-[600px]">
|
|
|
+ <table className="min-w-full border border-gray-300 text-sm table-fixed">
|
|
|
+ <thead>
|
|
|
+ <tr className="bg-gray-100 text-center sticky top-0 z-10">
|
|
|
+ <th className="border px-2 py-2 w-[10%] bg-gray-100">발주처</th>
|
|
|
+ <th className="border px-2 py-2 w-[40%] bg-gray-100">공사명</th>
|
|
|
+ <th className="border px-2 py-2 w-[15%] bg-gray-100">계약금액</th>
|
|
|
+ <th className="border px-2 py-2 w-[11%] bg-gray-100">계약일</th>
|
|
|
+ <th className="border px-2 py-2 w-[11%] bg-gray-100">착공일</th>
|
|
|
+ <th className="border px-2 py-2 w-[13%] bg-gray-100">준공일</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {filteredRows.length > 0 ? (
|
|
|
+ filteredRows.slice(0, -1).map((row: any, index: number) => (
|
|
|
+ <tr key={index} className={row._highlight ? "bg-yellow-200" : ""}>
|
|
|
+ <td className="border px-2 py-1 w-[10%] truncate font-bold">{row["발주처"]}</td>
|
|
|
+ <td className="border px-2 py-1 w-[40%] truncate">{row["공사명"]}</td>
|
|
|
+ <td className="border px-2 py-1 w-[15%] text-right">
|
|
|
+ {typeof row["계약금액"] === "number"
|
|
|
+ ? new Intl.NumberFormat("ko-KR").format(row["계약금액"])
|
|
|
+ : row["계약금액"]}
|
|
|
+ </td>
|
|
|
+ <td className="border px-2 py-1 w-[11%] text-center">{row["계약일"]}</td>
|
|
|
+ <td className="border px-2 py-1 w-[11%] text-center">{row["착공일"]}</td>
|
|
|
+ <td className="border px-2 py-1 w-[13%] text-center">{row["준공일"]}</td>
|
|
|
+ </tr>
|
|
|
+ ))
|
|
|
+ ) : (
|
|
|
+ <tr>
|
|
|
+ <td className="border px-4 py-2 text-center" colSpan={6}>
|
|
|
+ 데이터가 없습니다.
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ )}
|
|
|
+ </tbody>
|
|
|
+ <tfoot>
|
|
|
+ <tr className="bg-gray-100 font-bold text-center">
|
|
|
+ <td className="border px-2 py-1"/>
|
|
|
+ <td className="border px-2 py-1">합계</td>
|
|
|
+ <td className="border px-2 py-1 text-right">
|
|
|
+ {new Intl.NumberFormat("ko-KR").format(totalAmount)}
|
|
|
+ </td>
|
|
|
+ <td className="border px-2 py-1" colSpan={3}/>
|
|
|
+ </tr>
|
|
|
+ </tfoot>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </Tabs>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default Sheet;
|