479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
/**
|
|
* 📄 Table Modal
|
|
* 노후 하수관로 엑셀 데이터 테이블 모달 컴포넌트
|
|
*
|
|
* - 엑셀 파일 다운로드 및 특정 시트 파싱 기능
|
|
* - 탭 기반 테이블 표시 (row data, 정밀조사, 정비사업 등)
|
|
* - 데이터 필터링 및 빈 행 제거 처리
|
|
* - 로딩 오버레이 및 에러 핸들링 지원
|
|
* - XLSX 라이브러리를 통한 엑셀 데이터 변환
|
|
* - 테이블 헤더 및 데이터 동적 생성
|
|
*
|
|
* 🔗 사용 API
|
|
* - 첨부파일 다운로드(파일ID): GET /attach-service/api/v1/download/{atflId} (attach-service)
|
|
*/
|
|
'use client'
|
|
import { useState, useEffect } from 'react'
|
|
import LoadingOverlay from '@/components/ui/LoadingBar'
|
|
import { UITab, UiTable } from '@common/business_components/ui'
|
|
import * as XLSX from 'xlsx';
|
|
import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan';
|
|
import { oldPpTable2HeadList, oldPpTable2NodeList, oldPpTable6HeadList, oldPpTable6NodeList } from './headList';
|
|
|
|
const TABS = [
|
|
{ val: "1", text: "row data" },
|
|
{ val: "2", text: "1.정밀조사" },
|
|
{ val: "3", text: "2.정밀조사_정비사업연계" },
|
|
{ val: "4", text: " 3.정비사업" },
|
|
{ val: "5", text: "4.정비사업요약" },
|
|
{ val: "6", text: "4.정비사업최종" },
|
|
];
|
|
|
|
interface TableModalProps {
|
|
fileNm: string;
|
|
atflId: string;
|
|
}
|
|
|
|
const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
|
|
|
const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? "1");
|
|
const [tableHeader1, setTableHeader1] = useState<{ id: string; title: string }[]>([])
|
|
const [tableHeader2, setTableHeader2] = useState<{ id: string; title: string }[]>([])
|
|
const [tableHeader4, setTableHeader4] = useState<{ id: string; title: string }[]>([])
|
|
const [tableHeader5, setTableHeader5] = useState<{ id: string; title: string }[]>([])
|
|
const [tableData1, setTableData1] = useState<any>([{}])
|
|
const [tableData2, setTableData2] = useState<any>([{}])
|
|
const [tableData3, setTableData3] = useState<any>([{}])
|
|
const [tableData4, setTableData4] = useState<any>([{}])
|
|
const [tableData5, setTableData5] = useState<any>([{}])
|
|
const [tableData6, setTableData6] = useState<any>([{}])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
isChckFile();
|
|
}, []);
|
|
|
|
// 파일 다운로드
|
|
const isChckFile = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
if (fileNm === '' || fileNm === undefined) {
|
|
alert('파일을 선택해주세요');
|
|
setIsLoading(false);
|
|
return false;
|
|
}
|
|
|
|
const response = await fetch(`/attach-service/api/v1/download/${atflId}`);
|
|
if (!response.ok) {
|
|
throw new Error('파일을 가져오는 중 오류가 발생했습니다.');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
|
|
const file = new File([blob], fileNm, {
|
|
type: blob.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
});
|
|
|
|
const targetSheets = ['row data', '1.정밀조사', '2.정밀조사_정비사업연계', ' 3.정비사업', '4.정비사업요약', '4.정비사업최종'];
|
|
const jsonDataBySheet = await filePickedSelectedSheets(file, 0, targetSheets);
|
|
|
|
const header1 = jsonDataBySheet['row data']?.[1] || [];
|
|
const header2 = jsonDataBySheet['1.정밀조사']?.[1] || [];
|
|
const header4 = jsonDataBySheet[' 3.정비사업']?.[1] || [];
|
|
const header5 = jsonDataBySheet['4.정비사업요약']?.[2] || [];
|
|
|
|
// row data
|
|
const allEntries1 = Object.entries(header1);
|
|
const filteredHeader1 = [
|
|
allEntries1[0],
|
|
allEntries1[1],
|
|
allEntries1[2],
|
|
allEntries1[3],
|
|
allEntries1[5],
|
|
allEntries1[6],
|
|
allEntries1[7],
|
|
allEntries1[8],
|
|
allEntries1[9],
|
|
allEntries1[10],
|
|
allEntries1[11],
|
|
allEntries1[12],
|
|
allEntries1[13],
|
|
allEntries1[14],
|
|
allEntries1[15]
|
|
].filter((e): e is [string, any] => Boolean(e))
|
|
.map(([key, value]) => ({
|
|
id: key,
|
|
title: String(value).replace(/\r\n/g, '\n')
|
|
}));
|
|
filteredHeader1.splice(0, 0, {
|
|
id: "rowNumber",
|
|
title: '일련번호'
|
|
});
|
|
setTableHeader1(filteredHeader1);
|
|
|
|
const data1 = Array.isArray(jsonDataBySheet['row data'])
|
|
? jsonDataBySheet['row data'].slice(2)
|
|
: [];
|
|
const filteredData1 = filterEmptyRows(data1).map((item: any, index: number) => ({
|
|
...item,
|
|
rowNumber: index + 1,
|
|
__EMPTY_1: item.__EMPTY_1 || '-'
|
|
}));
|
|
setTableData1(filteredData1);
|
|
|
|
// 1.정밀조사
|
|
const allEntries2 = Object.entries(header2);
|
|
let filteredHeader2 = [
|
|
allEntries2[0],
|
|
allEntries2[1],
|
|
allEntries2[2],
|
|
allEntries2[3],
|
|
allEntries2[5],
|
|
allEntries2[6],
|
|
allEntries2[7],
|
|
allEntries2[8],
|
|
allEntries2[9],
|
|
allEntries2[10],
|
|
allEntries2[11],
|
|
allEntries2[12],
|
|
allEntries2[13],
|
|
allEntries2[14],
|
|
allEntries2[15]
|
|
].filter((e): e is [string, any] => Boolean(e))
|
|
.map(([key, value]) => ({
|
|
id: key,
|
|
title: String(value).replace(/\r\n/g, '\n')
|
|
}));
|
|
filteredHeader2.splice(0, 0, {
|
|
id: "rowNumber",
|
|
title: '일련번호'
|
|
});
|
|
setTableHeader2(filteredHeader2);
|
|
|
|
const data2 = Array.isArray(jsonDataBySheet['1.정밀조사'])
|
|
? jsonDataBySheet['1.정밀조사'].slice(2)
|
|
: [];
|
|
const filteredData2 = filterEmptyRows(data2).map((item: any, index: number) => ({
|
|
...item,
|
|
rowNumber: index + 1
|
|
}));
|
|
setTableData2(filteredData2);
|
|
|
|
// 2.정밀조사_정비사업연계 (헤더는 import)
|
|
const data3 = Array.isArray(jsonDataBySheet['2.정밀조사_정비사업연계'])
|
|
? jsonDataBySheet['2.정밀조사_정비사업연계'].slice(5)
|
|
: [];
|
|
const filteredData3 = filterEmptyRows(data3).map((item: any, index: number) => {
|
|
const newItem = { ...item };
|
|
// 매핑 에러로 인해 특정 키를 "__EMPTY_0"으로 변경
|
|
const targetKey = "*1차 출처: 공단자료\r\n*2차 출처: 정산서, 정밀조사보고서 등.\r\n (파랑) 2024년말 기준 실적 제출 자료. 준공 후 재확인 필요\r\n (노랑셀) 준공(또는 정산) 후에 재확인 필요";
|
|
if (newItem[targetKey] !== undefined) {
|
|
newItem["__EMPTY_0"] = newItem[targetKey];
|
|
delete newItem[targetKey];
|
|
}
|
|
return {
|
|
...newItem,
|
|
__EMPTY_4: newItem?.__EMPTY_4 || '-',
|
|
rowNumber: index + 1
|
|
};
|
|
});
|
|
setTableData3(filteredData3);
|
|
|
|
// 3.정비사업
|
|
const allEntries4 = Object.entries(header4);
|
|
const filteredHeader4 = allEntries4.filter((e): e is [string, any] => Boolean(e))
|
|
.map(([key, value]) => ({
|
|
id: key,
|
|
title: String(value).replace(/\r\n/g, '\n')
|
|
}));
|
|
|
|
filteredHeader4.splice(0, 0, {
|
|
id: "rowNumber",
|
|
title: '일련번호'
|
|
});
|
|
setTableHeader4(filteredHeader4);
|
|
|
|
const data4 = Array.isArray(jsonDataBySheet[' 3.정비사업'])
|
|
? jsonDataBySheet[' 3.정비사업'].slice(2)
|
|
: [];
|
|
const filteredData4 = filterEmptyRows(data4).map((item: any, index: number) => ({
|
|
...item,
|
|
rowNumber: index + 1,
|
|
__EMPTY_2: item?.__EMPTY_2 || '-',
|
|
__EMPTY_11: item?.__EMPTY_11 || '-',
|
|
}));
|
|
setTableData4(filteredData4);
|
|
|
|
// 4.정비사업요약
|
|
const allEntries5 = Object.entries(header5);
|
|
const excludeKeys5 = [
|
|
": \"정비사업\"sheet에서 사업코드 ctrl+c, Ctrl+v 한 다음 중복된 데이터 제거실시",
|
|
"__EMPTY",
|
|
"__EMPTY_8",
|
|
"__EMPTY_15"
|
|
];
|
|
const filteredHeader5 = allEntries5
|
|
.filter(([key]) => !excludeKeys5.includes(key))
|
|
.filter((e): e is [string, any] => Boolean(e))
|
|
.map(([key, value]) => ({
|
|
id: key,
|
|
title: String(value).replace(/\r\n/g, '\n'),
|
|
}));
|
|
filteredHeader5.splice(0, 0, {
|
|
id: "rowNumber",
|
|
title: '일련번호'
|
|
});
|
|
setTableHeader5(filteredHeader5);
|
|
|
|
const data5 = Array.isArray(jsonDataBySheet['4.정비사업요약'])
|
|
? jsonDataBySheet['4.정비사업요약'].slice(3)
|
|
: [];
|
|
const filteredData5 =
|
|
filterEmptyRows(data5).map((item: any, index: number) => ({
|
|
...item,
|
|
rowNumber: index + 1,
|
|
}));
|
|
setTableData5(filteredData5);
|
|
|
|
// 4.정비사업최종 (헤더는 import)
|
|
const data6 = Array.isArray(jsonDataBySheet['4.정비사업최종'])
|
|
? jsonDataBySheet['4.정비사업최종'].slice(6)
|
|
: [];
|
|
const filteredData6 =
|
|
filterEmptyRows(data6).map((item: any, index: number) => ({
|
|
...item,
|
|
rowNumber: index + 1,
|
|
}));
|
|
setTableData6(filteredData6);
|
|
} catch (error) {
|
|
console.error('파일 처리 중 오류 발생:', error);
|
|
alert('파일 처리 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function filterEmptyRows(data: any[]) {
|
|
return data
|
|
.filter(row => {
|
|
return Object.entries(row).some(([key, value]) => {
|
|
const match = key.match(/^__EMPTY_(\d+)$/);
|
|
if (!match || parseInt(match[1] ?? '', 10) < 2) return false;
|
|
|
|
return value !== undefined &&
|
|
value !== null &&
|
|
String(value).trim() !== '' &&
|
|
String(value).trim() !== '-' &&
|
|
String(value).trim() !== '';
|
|
});
|
|
})
|
|
.map((row) => {
|
|
const processedRow = { ...row };
|
|
const keys = Object.keys(processedRow);
|
|
|
|
keys.forEach((key, keyIndex) => {
|
|
// 마지막 속성(사업 근거)이 아닌 경우에만 빈 값을 '-'으로 변경
|
|
if (keyIndex !== keys.length - 1) {
|
|
const value = processedRow[key];
|
|
if (value === undefined ||
|
|
value === null ||
|
|
value === '' ||
|
|
String(value).trim() === '') {
|
|
processedRow[key] = '-';
|
|
}
|
|
}
|
|
});
|
|
|
|
return processedRow;
|
|
});
|
|
};
|
|
|
|
// 특정 시트만 선택적으로 JSON으로 변환하는 함수
|
|
function filePickedSelectedSheets(file: File, headerNum: number = 0, sheetNames: string[]): Promise<Record<string, any[]>> {
|
|
const reader = new FileReader();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
reader.onload = function (e) {
|
|
const data = e.target?.result;
|
|
if (!data) {
|
|
console.error("Failed to read file: e.target.result is null");
|
|
reject(new Error("Failed to read file"));
|
|
return;
|
|
}
|
|
|
|
// Excel 파일 읽기
|
|
const wb = XLSX.read(data, { type: 'binary', cellDates: true });
|
|
const result: Record<string, any[]> = {};
|
|
|
|
// 지정된 시트 이름만 찾아서 처리
|
|
sheetNames.forEach(targetSheetName => {
|
|
// 대소문자 구분 없이 시트 이름 찾기
|
|
const sheetName = wb.SheetNames.find(name =>
|
|
name.toLowerCase() === targetSheetName.toLowerCase()
|
|
);
|
|
|
|
if (sheetName) {
|
|
const sheet = wb.Sheets[sheetName];
|
|
if (sheet) {
|
|
const jsonData = XLSX.utils.sheet_to_json(sheet, { range: headerNum, raw: false });
|
|
result[targetSheetName] = jsonData;
|
|
}
|
|
} else {
|
|
console.warn(`Sheet "${targetSheetName}" not found in workbook`);
|
|
result[targetSheetName] = [];
|
|
}
|
|
});
|
|
|
|
resolve(result);
|
|
};
|
|
|
|
reader.onerror = function () {
|
|
console.error("Error reading file");
|
|
reject(new Error("Error reading file"));
|
|
};
|
|
|
|
// 파일 읽기 시작
|
|
reader.readAsBinaryString(file);
|
|
});
|
|
};
|
|
|
|
|
|
return (
|
|
<>
|
|
{/* 탭 영역 */}
|
|
<UITab
|
|
defaultValue={activeTab}
|
|
tabList={TABS.map((tab) => ({
|
|
value: tab.val,
|
|
label: tab.text
|
|
}))}
|
|
onValueChange={(value: string) => setActiveTab(value)}
|
|
/>
|
|
{/* row data 탭 */}
|
|
{activeTab === '1' && (
|
|
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
|
<div className="flex-1 px-2 pb-2">
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
<div className="min-w-[150%]">
|
|
<UiTable
|
|
headName={'row data'}
|
|
tableData={tableData1}
|
|
headlist={tableHeader1}
|
|
pageSize={10}
|
|
total={0}
|
|
cellClick={undefined}
|
|
onPageChange={() => { }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 1.정밀조사 탭 */}
|
|
{activeTab === '2' && (
|
|
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
|
<div className="flex-1 px-2 pb-2">
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
<div className="min-w-[150%]">
|
|
<UiTable
|
|
headName={'1.정밀조사'}
|
|
tableData={tableData2}
|
|
headlist={tableHeader2}
|
|
pageSize={10}
|
|
total={0}
|
|
className={"overflow-x-auto overflow-y-auto"}
|
|
cellClick={undefined}
|
|
onPageChange={() => { }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 2.정밀조사_정비사업연계 탭 */}
|
|
{activeTab === '3' && (
|
|
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
|
<div className="flex-1 px-2 pb-2">
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
<div className="min-w-[550%]">
|
|
<UiTable
|
|
headName={'2.정밀조사_정비사업연계'}
|
|
tableData={tableData3}
|
|
headlist={oldPpTable2HeadList}
|
|
pageSize={10}
|
|
total={0}
|
|
className={"overflow-x-auto overflow-y-auto"}
|
|
cellClick={undefined}
|
|
onPageChange={() => { }}
|
|
>
|
|
<HeadMakeColSpan upHeadList={oldPpTable2NodeList} />
|
|
</UiTable>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 3.정비사업 탭 */}
|
|
{activeTab === '4' && (
|
|
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
|
<div className="flex-1 px-2 pb-2">
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
<div className="min-w-[160%]">
|
|
<UiTable
|
|
headName={'3.정비사업'}
|
|
tableData={tableData4}
|
|
headlist={tableHeader4}
|
|
pageSize={10}
|
|
total={0}
|
|
className={"overflow-x-auto overflow-y-auto"}
|
|
cellClick={undefined}
|
|
onPageChange={() => { }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 4.정비사업요약 탭 */}
|
|
{activeTab === '5' && (
|
|
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
|
<div className="flex-1 px-2 pb-2">
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
<div className="min-w-[130%]">
|
|
<UiTable
|
|
headName={'4.정비사업요약'}
|
|
tableData={tableData5}
|
|
headlist={tableHeader5}
|
|
pageSize={10}
|
|
total={0}
|
|
className={"overflow-x-auto overflow-y-auto"}
|
|
cellClick={undefined}
|
|
onPageChange={() => { }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 4.정비사업최종 탭 */}
|
|
{activeTab === '6' && (
|
|
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
|
<div className="flex-1 px-2 pb-2">
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
<div className="min-w-[200%]">
|
|
<UiTable
|
|
headName={'4.정비사업최종'}
|
|
tableData={tableData6}
|
|
headlist={oldPpTable6HeadList}
|
|
pageSize={10}
|
|
total={0}
|
|
className={"overflow-x-auto overflow-y-auto"}
|
|
cellClick={undefined}
|
|
onPageChange={() => { }}
|
|
>
|
|
<HeadMakeColSpan upHeadList={oldPpTable6NodeList} />
|
|
</UiTable>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
};
|
|
|
|
export default TableModal; |