Files
frontend/apps/st/src/components/statistics/management/hooks/usePopulationMappingTab.ts
T

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,
};
}