200 lines
7.9 KiB
TypeScript
200 lines
7.9 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import * as XLSX from "xlsx";
|
|
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { toGridRows } from "@/components/util/grid/gridFunction";
|
|
|
|
import { useAsyncAction } from "./useAsyncAction";
|
|
import type {
|
|
PopulationMappingApiConfig,
|
|
PopulationMappingRow,
|
|
UpperCodeCandidateItem,
|
|
} from "@/components/statistics/management/types/population-tab-types";
|
|
|
|
/* ── 엑셀 헤더 검증 (원본 컴포넌트의 validateExcelHeader를 그대로 이동) ── */
|
|
const validateExcelHeader = (file: File, requiredKeywords: string[]): Promise<boolean> => {
|
|
return new Promise((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const data = e.target?.result;
|
|
const workbook = XLSX.read(data, { type: "binary" });
|
|
const sheetName = workbook.SheetNames[0];
|
|
if (!sheetName) { resolve(false); return; }
|
|
const sheet = workbook.Sheets[sheetName];
|
|
if (!sheet) { resolve(false); return; }
|
|
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 });
|
|
const headerRow = (rows[0] as string[]).map((h) => String(h ?? ""));
|
|
const isValid = requiredKeywords.every((kw) => headerRow.some((h) => h.includes(kw)));
|
|
resolve(isValid);
|
|
} catch {
|
|
resolve(false);
|
|
}
|
|
};
|
|
reader.readAsBinaryString(file);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 내국인/외국인 탭이 공유하는 "좌측 전체 리스트 + 우측 미매핑 리스트" 패턴 훅.
|
|
* 두 탭은 API 함수와 필드명만 다를 뿐 동작이 완전히 동일해서 config로 주입받는다.
|
|
*/
|
|
export function usePopulationMappingTab<TRow extends PopulationMappingRow>(
|
|
config: PopulationMappingApiConfig<TRow>,
|
|
) {
|
|
const { setMessage: alert } = useAlertStore((state) => state);
|
|
const { isLoading, run } = useAsyncAction(alert);
|
|
|
|
const [leftRowData, setLeftRowData] = useState<TRow[]>([]);
|
|
const [rightRowData, setRightRowData] = useState<TRow[]>([]);
|
|
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [candidates, setCandidates] = useState<UpperCodeCandidateItem[]>([]);
|
|
const selectedRowIndexRef = useRef<number | null>(null);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const loadLeft = async (statsYr: string) => {
|
|
const res = await config.getList(statsYr, "1");
|
|
if (res.status === "success") setLeftRowData(toGridRows((res.data as object[]) ?? []) as unknown as TRow[]);
|
|
};
|
|
|
|
const loadRight = async (statsYr: string) => {
|
|
const res = await config.getList(statsYr, "2");
|
|
if (res.status === "success") setRightRowData(toGridRows((res.data as object[]) ?? []) as unknown as TRow[]);
|
|
};
|
|
|
|
/** 탭 최초 진입/활성화 시 - 좌/우 모두 갱신 (업로드 직후에도 사용) */
|
|
const loadAll = async (statsYr: string): Promise<boolean> => {
|
|
if (!statsYr) { alert("연도를 선택해주세요."); return false; }
|
|
const result = await run(async () => {
|
|
await loadLeft(statsYr);
|
|
await loadRight(statsYr);
|
|
return true;
|
|
});
|
|
return result ?? false;
|
|
};
|
|
|
|
/**
|
|
* 검색 버튼 클릭 시 - 좌측은 항상 갱신하고, 우측은 비어있을 때만 갱신한다.
|
|
* (사용자가 우측에서 수동으로 매핑한 값을 재검색 시 날려버리지 않기 위함 - 원본 동작 그대로 유지)
|
|
*/
|
|
const search = async (statsYr: string): Promise<boolean> => {
|
|
if (!statsYr) { alert("연도를 선택해주세요."); return false; }
|
|
const result = await run(async () => {
|
|
await loadLeft(statsYr);
|
|
if (rightRowData.length === 0) await loadRight(statsYr);
|
|
return true;
|
|
});
|
|
return result ?? false;
|
|
};
|
|
|
|
/** 탭 전환 시 - 아직 한 번도 로드되지 않았을 때만 로드 */
|
|
const loadOnActivate = async (statsYr: string) => {
|
|
if (leftRowData.length === 0) await loadAll(statsYr);
|
|
};
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>, statsYr: string) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const isValidExt = [".xlsx", ".xls", ".csv"].some((ext) => file.name.toLowerCase().endsWith(ext));
|
|
if (!isValidExt) { alert("xlsx, xls, csv 파일만 업로드 가능합니다."); e.target.value = ""; return; }
|
|
|
|
const isValidHeader = await validateExcelHeader(file, config.requiredHeaderKeywords);
|
|
if (!isValidHeader) { alert(config.invalidFileMessage); e.target.value = ""; return; }
|
|
|
|
if (!statsYr) { alert("연도를 선택해주세요."); e.target.value = ""; return; }
|
|
|
|
await run(async () => {
|
|
const res = await config.uploadExcel(file, statsYr);
|
|
if (res.status === "success") {
|
|
await loadAll(statsYr);
|
|
alert("업로드가 완료되었습니다.");
|
|
} else {
|
|
alert(res.message || "업로드 중 오류가 발생했습니다.");
|
|
}
|
|
});
|
|
e.target.value = "";
|
|
};
|
|
|
|
const handleFinal = async (statsYr: string) => {
|
|
if (!statsYr) { alert("먼저 조회를 진행해주세요."); return; }
|
|
if (rightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; }
|
|
|
|
await run(async () => {
|
|
const regList = rightRowData
|
|
.filter((row) => String(row.mpinCd ?? "").trim() !== "")
|
|
.map((row) => ({
|
|
statsYr,
|
|
admdstCd: String(row.admdstCd ?? ""),
|
|
admdstNm: String(row.admdstNm ?? ""),
|
|
subAdmdstCd: String(row.mpinCd ?? ""),
|
|
}));
|
|
|
|
const res = await config.saveFinal(statsYr, regList);
|
|
if (res.status === "success") {
|
|
await loadLeft(statsYr);
|
|
alert("반영이 완료되었습니다.");
|
|
} else {
|
|
alert(res.message || "반영 중 오류가 발생했습니다.");
|
|
}
|
|
});
|
|
};
|
|
const handleRightRowClick = async (rowData: TRow, statsYr: string) => {
|
|
const admdstCd = String(rowData.admdstCd ?? "");
|
|
if (!admdstCd) { alert("행정구역코드가 없습니다."); return; }
|
|
|
|
await run(
|
|
async () => {
|
|
const res = await config.getUpperCodesByName(statsYr, "", admdstCd); // ← admdstCd 추가
|
|
if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) {
|
|
setCandidates(res.data);
|
|
const rowIndex = rightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd);
|
|
selectedRowIndexRef.current = rowIndex;
|
|
setDialogOpen(true);
|
|
} else {
|
|
alert("해당 지역명에 대한 상위 코드 후보가 없습니다.");
|
|
}
|
|
},
|
|
{ errorMessage: "상위 코드 조회 중 오류가 발생했습니다." },
|
|
);
|
|
};
|
|
|
|
const handleCodeSelect = (item: Record<string, string>) => {
|
|
const rowIndex = selectedRowIndexRef.current;
|
|
if (rowIndex === null) return;
|
|
setRightRowData((prev) =>
|
|
prev.map((row, idx) => (idx === rowIndex ? { ...row, mpinCd: item["admdstCd"] ?? "" } : row)),
|
|
);
|
|
selectedRowIndexRef.current = null;
|
|
};
|
|
|
|
const reset = () => {
|
|
setLeftRowData([]);
|
|
setRightRowData([]);
|
|
setDialogOpen(false);
|
|
setCandidates([]);
|
|
selectedRowIndexRef.current = null;
|
|
};
|
|
|
|
return {
|
|
isLoading,
|
|
leftRowData,
|
|
rightRowData,
|
|
setLeftRowData,
|
|
setRightRowData,
|
|
fileInputRef,
|
|
dialogOpen,
|
|
setDialogOpen,
|
|
candidates,
|
|
loadAll,
|
|
search,
|
|
loadOnActivate,
|
|
handleFileChange,
|
|
handleFinal,
|
|
handleRightRowClick,
|
|
handleCodeSelect,
|
|
reset,
|
|
};
|
|
} |