366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
/** 노후 하수관로 정비사업 첨부 엑셀 상세 팝업 테이블 */
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
getCoreRowModel,
|
|
getPaginationRowModel,
|
|
useReactTable,
|
|
type ColumnDef,
|
|
} from "@tanstack/react-table";
|
|
import * as XLSX from "xlsx";
|
|
|
|
import { getBlobData } from "@/components/lib/api";
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { StandardDataTable } from "@common/business_components";
|
|
import { UITab } from "@common/business_components/ui";
|
|
import { oldPpTable2HeadList, oldPpTable6HeadList } from "./headList";
|
|
|
|
const TABS = [
|
|
{ val: "1", text: "raw data" },
|
|
{ val: "2", text: "1.정밀조사" },
|
|
{ val: "3", text: "2.정밀조사_정비사업연계" },
|
|
{ val: "4", text: "3.정비사업" },
|
|
{ val: "5", text: "4.정비사업요약" },
|
|
{ val: "6", text: "4.정비사업최종" },
|
|
];
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
type ExcelHeader = {
|
|
id: string;
|
|
title: string;
|
|
};
|
|
|
|
type ExcelRow = Record<string, unknown>;
|
|
|
|
interface TableModalProps {
|
|
fileNm: string;
|
|
atflId: string;
|
|
}
|
|
|
|
function normalizeHeaderTitle(value: unknown) {
|
|
const title = String(value).replace(/\r\n/g, "\n");
|
|
return /^(no|일련번호)$/i.test(title.trim()) ? "순번" : title;
|
|
}
|
|
|
|
function createColumns(headers: ExcelHeader[]): ColumnDef<ExcelRow>[] {
|
|
return headers.map((header) => ({
|
|
id: header.id,
|
|
accessorFn: (row) => row[header.id],
|
|
header: () => <span className="whitespace-pre-line">{header.title}</span>,
|
|
cell: ({ getValue }) => String(getValue() ?? "-"),
|
|
size: 140,
|
|
meta: { align: "center" },
|
|
}));
|
|
}
|
|
|
|
function useExcelTable(data: ExcelRow[], headers: ExcelHeader[]) {
|
|
const columns = useMemo(() => createColumns(headers), [headers]);
|
|
return useReactTable({
|
|
data,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
initialState: { pagination: { pageIndex: 0, pageSize: PAGE_SIZE } },
|
|
});
|
|
}
|
|
|
|
function filterEmptyRows(data: ExcelRow[]) {
|
|
return data
|
|
.filter((row) =>
|
|
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() !== "-"
|
|
);
|
|
}),
|
|
)
|
|
.map((row) => {
|
|
const processedRow = { ...row };
|
|
const keys = Object.keys(processedRow);
|
|
keys.forEach((key, keyIndex) => {
|
|
if (keyIndex === keys.length - 1) return;
|
|
|
|
const value = processedRow[key];
|
|
if (
|
|
value === undefined ||
|
|
value === null ||
|
|
value === "" ||
|
|
String(value).trim() === ""
|
|
) {
|
|
processedRow[key] = "-";
|
|
}
|
|
});
|
|
return processedRow;
|
|
});
|
|
}
|
|
|
|
function withDescendingRowNumber(rows: ExcelRow[]) {
|
|
return rows.map((row, index) => ({
|
|
...row,
|
|
rowNumber: rows.length - index,
|
|
}));
|
|
}
|
|
|
|
function filePickedSelectedSheets(
|
|
file: File,
|
|
headerNum: number = 0,
|
|
sheetNames: string[],
|
|
): Promise<Record<string, ExcelRow[]>> {
|
|
const reader = new FileReader();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
reader.onload = (event) => {
|
|
const data = event.target?.result;
|
|
if (!data) {
|
|
reject(new Error("Failed to read file"));
|
|
return;
|
|
}
|
|
|
|
const workbook = XLSX.read(data, { type: "binary", cellDates: true });
|
|
const result: Record<string, ExcelRow[]> = {};
|
|
|
|
sheetNames.forEach((targetSheetName) => {
|
|
const sheetName = workbook.SheetNames.find(
|
|
(name) => name.toLowerCase() === targetSheetName.toLowerCase(),
|
|
);
|
|
const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
|
|
if (!sheet) {
|
|
console.warn(`Sheet "${targetSheetName}" not found in workbook`);
|
|
result[targetSheetName] = [];
|
|
return;
|
|
}
|
|
|
|
result[targetSheetName] = XLSX.utils.sheet_to_json<ExcelRow>(sheet, {
|
|
range: headerNum,
|
|
raw: false,
|
|
});
|
|
});
|
|
|
|
resolve(result);
|
|
};
|
|
|
|
reader.onerror = () => reject(new Error("Error reading file"));
|
|
reader.readAsBinaryString(file);
|
|
});
|
|
}
|
|
|
|
const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
|
const alert = useAlertStore((state) => state.setMessage);
|
|
const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? "1");
|
|
const [headers1, setHeaders1] = useState<ExcelHeader[]>([]);
|
|
const [headers2, setHeaders2] = useState<ExcelHeader[]>([]);
|
|
const [headers4, setHeaders4] = useState<ExcelHeader[]>([]);
|
|
const [headers5, setHeaders5] = useState<ExcelHeader[]>([]);
|
|
const [data1, setData1] = useState<ExcelRow[]>([]);
|
|
const [data2, setData2] = useState<ExcelRow[]>([]);
|
|
const [data3, setData3] = useState<ExcelRow[]>([]);
|
|
const [data4, setData4] = useState<ExcelRow[]>([]);
|
|
const [data5, setData5] = useState<ExcelRow[]>([]);
|
|
const [data6, setData6] = useState<ExcelRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const headers3 = useMemo<ExcelHeader[]>(
|
|
() =>
|
|
oldPpTable2HeadList.map((header) => ({
|
|
id: header.id,
|
|
title: normalizeHeaderTitle(header.title),
|
|
})),
|
|
[],
|
|
);
|
|
const headers6 = useMemo<ExcelHeader[]>(
|
|
() =>
|
|
oldPpTable6HeadList.map((header) => ({
|
|
id: header.id,
|
|
title: normalizeHeaderTitle(header.title),
|
|
})),
|
|
[],
|
|
);
|
|
const table1 = useExcelTable(data1, headers1);
|
|
const table2 = useExcelTable(data2, headers2);
|
|
const table3 = useExcelTable(data3, headers3);
|
|
const table4 = useExcelTable(data4, headers4);
|
|
const table5 = useExcelTable(data5, headers5);
|
|
const table6 = useExcelTable(data6, headers6);
|
|
|
|
useEffect(() => {
|
|
const loadFile = async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
if (!fileNm) {
|
|
alert("파일을 선택해주세요");
|
|
return;
|
|
}
|
|
if (!atflId) {
|
|
alert("첨부파일 ID가 없어 파일을 불러올 수 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const blob = await getBlobData(
|
|
`/attach-service/api/v1/download/${atflId}`,
|
|
);
|
|
const file = new File([blob], fileNm, {
|
|
type:
|
|
blob.type ||
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
});
|
|
const jsonDataBySheet = await filePickedSelectedSheets(file, 0, [
|
|
"raw data",
|
|
"1.정밀조사",
|
|
"2.정밀조사_정비사업연계",
|
|
" 3.정비사업",
|
|
"4.정비사업요약",
|
|
"4.정비사업최종",
|
|
]);
|
|
|
|
const header1Entries = Object.entries(
|
|
jsonDataBySheet["raw data"]?.[1] ?? {},
|
|
);
|
|
const header2Entries = Object.entries(
|
|
jsonDataBySheet["1.정밀조사"]?.[1] ?? {},
|
|
);
|
|
const selectedIndexes = [
|
|
0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
|
|
];
|
|
const selectHeaders = (entries: [string, unknown][]) => [
|
|
{ id: "rowNumber", title: "순번" },
|
|
...selectedIndexes
|
|
.map((index) => entries[index])
|
|
.filter((entry): entry is [string, unknown] => Boolean(entry))
|
|
.map(([id, title]) => ({
|
|
id,
|
|
title: normalizeHeaderTitle(title),
|
|
})),
|
|
];
|
|
setHeaders1(selectHeaders(header1Entries));
|
|
setHeaders2(selectHeaders(header2Entries));
|
|
|
|
const nextData1 = filterEmptyRows(
|
|
(jsonDataBySheet["raw data"] ?? []).slice(2),
|
|
).map((item) => ({
|
|
...item,
|
|
__EMPTY_1: item.__EMPTY_1 || "-",
|
|
}));
|
|
setData1(withDescendingRowNumber(nextData1));
|
|
setData2(
|
|
withDescendingRowNumber(
|
|
filterEmptyRows((jsonDataBySheet["1.정밀조사"] ?? []).slice(2)),
|
|
),
|
|
);
|
|
|
|
const sourceDescriptionKey =
|
|
"*1차 출처: 공단자료\r\n*2차 출처: 정산서, 정밀조사보고서 등.\r\n (파랑) 2024년말 기준 실적 제출 자료. 준공 후 재확인 필요\r\n (노랑셀) 준공(또는 정산) 후에 재확인 필요";
|
|
const nextData3 = filterEmptyRows(
|
|
(jsonDataBySheet["2.정밀조사_정비사업연계"] ?? []).slice(5),
|
|
).map((item) => {
|
|
const next = { ...item };
|
|
if (next[sourceDescriptionKey] !== undefined) {
|
|
next.__EMPTY_0 = next[sourceDescriptionKey];
|
|
delete next[sourceDescriptionKey];
|
|
}
|
|
return { ...next, __EMPTY_4: next.__EMPTY_4 || "-" };
|
|
});
|
|
setData3(withDescendingRowNumber(nextData3));
|
|
|
|
const header4Entries = Object.entries(
|
|
jsonDataBySheet[" 3.정비사업"]?.[1] ?? {},
|
|
);
|
|
setHeaders4([
|
|
{ id: "rowNumber", title: "순번" },
|
|
...header4Entries.map(([id, title]) => ({
|
|
id,
|
|
title: normalizeHeaderTitle(title),
|
|
})),
|
|
]);
|
|
const nextData4 = filterEmptyRows(
|
|
(jsonDataBySheet[" 3.정비사업"] ?? []).slice(2),
|
|
).map((item) => ({
|
|
...item,
|
|
__EMPTY_2: item.__EMPTY_2 || "-",
|
|
__EMPTY_11: item.__EMPTY_11 || "-",
|
|
}));
|
|
setData4(withDescendingRowNumber(nextData4));
|
|
|
|
const excludedSummaryKeys = [
|
|
': "정비사업"sheet에서 사업코드 ctrl+c, Ctrl+v 한 다음 중복된 데이터 제거실시',
|
|
"__EMPTY",
|
|
"__EMPTY_8",
|
|
"__EMPTY_15",
|
|
];
|
|
const header5Entries = Object.entries(
|
|
jsonDataBySheet["4.정비사업요약"]?.[2] ?? {},
|
|
).filter(([key]) => !excludedSummaryKeys.includes(key));
|
|
setHeaders5([
|
|
{ id: "rowNumber", title: "순번" },
|
|
...header5Entries.map(([id, title]) => ({
|
|
id,
|
|
title: normalizeHeaderTitle(title),
|
|
})),
|
|
]);
|
|
setData5(
|
|
withDescendingRowNumber(
|
|
filterEmptyRows((jsonDataBySheet["4.정비사업요약"] ?? []).slice(3)),
|
|
),
|
|
);
|
|
setData6(
|
|
withDescendingRowNumber(
|
|
filterEmptyRows((jsonDataBySheet["4.정비사업최종"] ?? []).slice(6)),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
console.error("OldPp attachment fetch details:", {
|
|
atflId,
|
|
fileNm,
|
|
error,
|
|
});
|
|
alert("파일 처리 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadFile();
|
|
}, [alert, atflId, fileNm]);
|
|
|
|
const activeConfig =
|
|
activeTab === "1"
|
|
? { table: table1, headers: headers1 }
|
|
: activeTab === "2"
|
|
? { table: table2, headers: headers2 }
|
|
: activeTab === "3"
|
|
? { table: table3, headers: headers3 }
|
|
: activeTab === "4"
|
|
? { table: table4, headers: headers4 }
|
|
: activeTab === "5"
|
|
? { table: table5, headers: headers5 }
|
|
: { table: table6, headers: headers6 };
|
|
const activeTitle =
|
|
TABS.find((tab) => tab.val === activeTab)?.text ?? "첨부 데이터";
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
|
<UITab
|
|
defaultValue={activeTab}
|
|
tabList={TABS.map((tab) => ({ value: tab.val, label: tab.text }))}
|
|
onValueChange={setActiveTab}
|
|
/>
|
|
<StandardDataTable
|
|
table={activeConfig.table}
|
|
title={`${activeTitle} 목록`}
|
|
loading={loading}
|
|
scrollMode="horizontal"
|
|
minTableWidth={`${Math.max(activeConfig.headers.length * 9, 80)}rem`}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TableModal;
|