diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index 517d26ef5..5efa83ce0 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -625,6 +625,7 @@ const DataGrid = ({ onCellEdited, onRowClick, onDataCellClick, + onCellDblClick, onItemChecked, onItemAllChecked, onScrollToBottom, @@ -633,6 +634,7 @@ const DataGrid = ({ idFieldName = "id", enableSort = false, gridHeight = 55, // vh 단위 + onRowDblClick, headerHeight = 60, fitStyle = GridFitStyle.EVEN, }: DataGridProps) => { @@ -671,6 +673,7 @@ const DataGrid = ({ onCellEditedRef.current = onCellEdited; onDataCellClickRef.current = onDataCellClick; + // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { if (!gridRef.current?.gridView || highlightedRowId == null) return; @@ -686,7 +689,27 @@ const DataGrid = ({ gridView.setFocus(); } }, [highlightedRowId, rowData, idFieldName]); +// 행 더블클릭 이벤트 +useEffect(() => { + const gridView = gridRef.current?.gridView; + if (!gridView || !onCellDblClick) return; + (gridView as any).onCellDblClicked = (_grid: unknown, clickData: { cellType?: string; itemIndex?: number }) => { + if (clickData.cellType !== "data" || clickData.itemIndex === undefined) return; + const row = rowDataRef.current[clickData.itemIndex]; + const cellIndex = (gridView as any).getCurrent?.(); + const fieldName = cellIndex?.fieldName ?? cellIndex?.column ?? ""; + if (row) onCellDblClick({ // ← 여기는 Props 콜백 호출이라 그대로 + row: row as Record, + fieldName, + itemIndex: clickData.itemIndex + }); + }; + + return () => { + (gridView as any).onCellDblClicked = undefined; + }; +}, [onCellDblClick]); useLayoutEffect(() => { if (!cellStyleCallback || !gridRef.current?.gridView) return; gridRef.current.gridView.refresh(); diff --git a/apps/st/src/components/statistics/management/AdmdstRenewalTab.tsx b/apps/st/src/components/statistics/management/AdmdstRenewalTab.tsx new file mode 100644 index 000000000..e125ab3be --- /dev/null +++ b/apps/st/src/components/statistics/management/AdmdstRenewalTab.tsx @@ -0,0 +1,103 @@ +import { Button } from "@common/components"; +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import { + AdmdstRenewalReducedColumnConfig, + AdmdstRenewalAddedColumnConfig, +} from "@/components/util/column/management/AdmdstRenewalColumn"; + +import { UpperCodeSelectDialog } from "@/components/statistics/management/UpperCodeSelectDialog"; +import type { useAdmdstRenewalTab } from "@/components/statistics/management/hooks/useAdmdstRenewalTab"; +import { asGridRowData, asGridRowSetter, fromGridRow } from "@/components/statistics/management/lib/gridCast"; +import type { AdmdstRenewalAddedItem } from "@/components/statistics/management/types/population-tab-types"; + +const reducedColumnInfo: ColumnInfo = { + columns: AdmdstRenewalReducedColumnConfig.columns ?? [], + columnLayout: AdmdstRenewalReducedColumnConfig.columnLayout ?? [], +}; +const addedColumnInfo: ColumnInfo = { + columns: AdmdstRenewalAddedColumnConfig.columns ?? [], + columnLayout: AdmdstRenewalAddedColumnConfig.columnLayout ?? [], +}; + +type Props = { + tab: ReturnType; + statsYr: string; +}; + +const AdmdstRenewalTab = ({ tab, statsYr }: Props) => { + return ( +
+
+
+ + 감축된 행정구역(범위:동읍면) 자료 + +
+
+ +
+ +
+
+ + 추가된 행정구역 자료 + +
+
+ +
+ { + if (fieldName !== "lawCd") return; // lawCd 셀만! + void tab.handleRowClick(fromGridRow(row), statsYr); + }} + /> +
+ + +
+ ); +}; + +export default AdmdstRenewalTab; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/DomesticPopulationTab.tsx b/apps/st/src/components/statistics/management/DomesticPopulationTab.tsx new file mode 100644 index 000000000..e04bff56d --- /dev/null +++ b/apps/st/src/components/statistics/management/DomesticPopulationTab.tsx @@ -0,0 +1,134 @@ +import { Button } from "@common/components"; +import type { RealGridReact } from "realgrid-react"; +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import { + DomesticPopulationLeftColumnConfig, + DomesticPopulationRightColumnConfig, +} from "@/components/util/column/management/DomesticPopulationColumn"; +import { useRef } from "react"; + +import { UpperCodeSelectDialog } from "@/components/statistics/management/UpperCodeSelectDialog"; +import type { usePopulationMappingTab } from "@/components/statistics/management/hooks/usePopulationMappingTab"; +import { asGridRowData, asGridRowSetter, fromGridRow } from "@/components/statistics/management/lib/gridCast"; +import type { DomesticPopulationItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; +import type { DataCellClickCallbackParams } from "@/components/type/DataGrid.type"; + + +const leftColumnInfo: ColumnInfo = { + columns: DomesticPopulationLeftColumnConfig.columns ?? [], + columnLayout: DomesticPopulationLeftColumnConfig.columnLayout ?? [], +}; +const rightColumnInfo: ColumnInfo = { + columns: DomesticPopulationRightColumnConfig.columns ?? [], + columnLayout: DomesticPopulationRightColumnConfig.columnLayout ?? [], +}; + +type DomesticTab = ReturnType>; + +type Props = { + tab: DomesticTab; + statsYr: string; + onFileButtonClick: () => void; +}; + +const DomesticPopulationTab = ({ tab, statsYr, onFileButtonClick }: Props) => { + const rightGridRef = useRef(null); + + return ( +
+
+
+ + 엑셀 업로드 자료 (내국인 전체 리스트) + +
+
+ +
+ +
+ +
+
+ + 미매핑 리스트 (작년 대비) + +
+
+ +
+ { + if (fieldName === "mpinCd") return; + if (fieldName === "admdstCd") { + (rightGridRef.current?.gridView as any)?.commit?.(); // ← commit 먼저 + setTimeout(() => { + tab.setRightRowData((prev) => + prev.map((r) => + r.admdstCd === row.admdstCd + ? { ...r, mpinCd: String(row.admdstCd ?? "") } + : r, + ), + ); + }, 0); + return; + } + }} + onCellDblClick={({ row, fieldName }) => { + if (fieldName !== "mpinCd") return; + void tab.handleRightRowClick(fromGridRow(row), statsYr); + }} + /> +
+ + +
+ ); +}; + +export default DomesticPopulationTab; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/ForeignPopulationTab.tsx b/apps/st/src/components/statistics/management/ForeignPopulationTab.tsx new file mode 100644 index 000000000..adbab7ed7 --- /dev/null +++ b/apps/st/src/components/statistics/management/ForeignPopulationTab.tsx @@ -0,0 +1,132 @@ +import { Button } from "@common/components"; +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import { useRef } from "react"; +import type { RealGridReact } from "realgrid-react"; +import { + ForeignPopulationLeftColumnConfig, + ForeignPopulationRightColumnConfig, +} from "@/components/util/column/management/ForeignPopulationColumn"; + +import { UpperCodeSelectDialog } from "@/components/statistics/management/UpperCodeSelectDialog"; +import type { usePopulationMappingTab } from "@/components/statistics/management/hooks/usePopulationMappingTab"; +import { asGridRowData, asGridRowSetter, fromGridRow } from "@/components/statistics/management/lib/gridCast"; +import type { ForeignPopulationItem } from "@/components/statistics/management/types/population-tab-types"; + +const leftColumnInfo: ColumnInfo = { + columns: ForeignPopulationLeftColumnConfig.columns ?? [], + columnLayout: ForeignPopulationLeftColumnConfig.columnLayout ?? [], +}; +const rightColumnInfo: ColumnInfo = { + columns: ForeignPopulationRightColumnConfig.columns ?? [], + columnLayout: ForeignPopulationRightColumnConfig.columnLayout ?? [], +}; + +type ForeignTab = ReturnType>; + +type Props = { + tab: ForeignTab; + statsYr: string; + onFileButtonClick: () => void; +}; + +const ForeignPopulationTab = ({ tab, statsYr, onFileButtonClick }: Props) => { + const rightGridRef = useRef(null); + return ( +
+
+
+ + 엑셀 업로드 자료 (외국인 전체 리스트) + +
+
+ +
+ +
+ +
+
+ + 미매핑 리스트 (작년 대비) + +
+
+ +
+ + { + if (fieldName === "mpinCd") return; + if (fieldName === "admdstCd") { + (rightGridRef.current?.gridView as any)?.commit?.(); + setTimeout(() => { + tab.setRightRowData((prev) => + prev.map((r) => + r.admdstCd === row.admdstCd + ? { ...r, mpinCd: String(row.admdstCd ?? "") } + : r, + ), + ); + }, 0); + return; + } + }} + onCellDblClick={({ row, fieldName }) => { + if (fieldName !== "mpinCd") return; + void tab.handleRightRowClick(fromGridRow(row), statsYr); + }} + /> +
+ + +
+ ); +}; + +export default ForeignPopulationTab; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/PopulationStatusTab.tsx b/apps/st/src/components/statistics/management/PopulationStatusTab.tsx new file mode 100644 index 000000000..f84103d4d --- /dev/null +++ b/apps/st/src/components/statistics/management/PopulationStatusTab.tsx @@ -0,0 +1,42 @@ +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import { PopulationStatusColumnConfig } from "@/components/util/column/management/PopulationStatusColumn"; + +import type { usePopulationStatusTab } from "@/components/statistics/management/hooks/usePopulationStatusTab"; +import { asGridRowData, asGridRowSetter } from "@/components/statistics/management/lib/gridCast"; + +const columnInfo: ColumnInfo = { + columns: PopulationStatusColumnConfig.columns ?? [], + columnLayout: PopulationStatusColumnConfig.columnLayout ?? [], +}; + +type Props = { + tab: ReturnType; +}; + +const PopulationStatusTab = ({ tab }: Props) => { + return ( + <> +
+ + 행정 인구 현황 + +
+ + + + ); +}; + +export default PopulationStatusTab; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/PopulationVizTab.tsx b/apps/st/src/components/statistics/management/PopulationVizTab.tsx new file mode 100644 index 000000000..0792148ec --- /dev/null +++ b/apps/st/src/components/statistics/management/PopulationVizTab.tsx @@ -0,0 +1,22 @@ +import PopulationStatusChart from "./PopulationStatusChart"; +import type { usePopulationVizTab } from "@/components/statistics/management/hooks/usePopulationVizTab"; + +type Props = { + tab: ReturnType; +}; + +const PopulationVizTab = ({ tab }: Props) => { + return ( + <> + + 행정인구현황 시각화 (기준연도 ~ 과거 자료(최대 5년) / *단위: 명) + + +
+ +
+ + ); +}; + +export default PopulationVizTab; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx index 5c2f0cd0f..c33feff95 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -1,624 +1,225 @@ import { useRef, useState } from "react"; -import * as XLSX from "xlsx"; -import { useAlertStore } from "@/shared/lib"; import { Button, LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; import SearchForm from "@/components/module/SearchForm"; -import DataGrid from "@/components/module/DataGrid"; -import type { ColumnInfo } from "@/components/type/DataGrid.type"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; -import { toGridRows } from "@/components/util/grid/gridFunction"; - -import { - DomesticPopulationLeftColumnConfig, - DomesticPopulationRightColumnConfig, -} from "@/components/util/column/management/DomesticPopulationColumn"; -import { - ForeignPopulationLeftColumnConfig, - ForeignPopulationRightColumnConfig, -} from "@/components/util/column/management/ForeignPopulationColumn"; -import { - AdmdstRenewalReducedColumnConfig, - AdmdstRenewalAddedColumnConfig, -} from "@/components/util/column/management/AdmdstRenewalColumn"; -import { PopulationStatusColumnConfig } from "@/components/util/column/management/PopulationStatusColumn"; import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; -import { getUserInfo } from "../../../../../../packages/common/components/src/api"; -import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; -import { UpperCodeSelectDialog } from "./UpperCodeSelectDialog"; -import PopulationStatusChart, { type PopulationChartData } from "./PopulationStatusChart"; +import type { DomesticPopulationItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; + +import { usePopulationMappingTab } from "@/components/statistics/management/hooks/usePopulationMappingTab"; +import { useAdmdstRenewalTab } from "@/components/statistics/management/hooks/useAdmdstRenewalTab"; +import { usePopulationStatusTab } from "@/components/statistics/management/hooks/usePopulationStatusTab"; +import { usePopulationVizTab } from "@/components/statistics/management/hooks/usePopulationVizTab"; + +import DomesticPopulationTab from "./DomesticPopulationTab"; +import ForeignPopulationTab from "./ForeignPopulationTab"; +import AdmdstRenewalTab from "./AdmdstRenewalTab"; +import PopulationStatusTab from "./PopulationStatusTab"; +import PopulationVizTab from "./PopulationVizTab"; + +import type { ForeignPopulationItem } from "@/components/statistics/management/types/population-tab-types"; const TAB_LIST = [ - { key: "domestic", label: "행정인구등록" }, - { key: "foreign", label: "외국인인구등록" }, - { key: "renewal", label: "행정동갱신" }, - { key: "status", label: "행정인구현황" }, + { key: "domestic", label: "행정인구등록" }, + { key: "foreign", label: "외국인인구등록" }, + { key: "renewal", label: "행정동갱신" }, + { key: "status", label: "행정인구현황" }, { key: "statusViz", label: "행정인구현황 시각화" }, ]; -/* ── 컬럼 설정 → ColumnInfo 변환 ── */ -const domesticLeftColumnInfo: ColumnInfo = { - columns: DomesticPopulationLeftColumnConfig.columns ?? [], - columnLayout: DomesticPopulationLeftColumnConfig.columnLayout ?? [], -}; -const domesticRightColumnInfo: ColumnInfo = { - columns: DomesticPopulationRightColumnConfig.columns ?? [], - columnLayout: DomesticPopulationRightColumnConfig.columnLayout ?? [], -}; -const foreignLeftColumnInfo: ColumnInfo = { - columns: ForeignPopulationLeftColumnConfig.columns ?? [], - columnLayout: ForeignPopulationLeftColumnConfig.columnLayout ?? [], -}; -const foreignRightColumnInfo: ColumnInfo = { - columns: ForeignPopulationRightColumnConfig.columns ?? [], - columnLayout: ForeignPopulationRightColumnConfig.columnLayout ?? [], -}; -const renewalReducedColumnInfo: ColumnInfo = { - columns: AdmdstRenewalReducedColumnConfig.columns ?? [], - columnLayout: AdmdstRenewalReducedColumnConfig.columnLayout ?? [], -}; -const renewalAddedColumnInfo: ColumnInfo = { - columns: AdmdstRenewalAddedColumnConfig.columns ?? [], - columnLayout: AdmdstRenewalAddedColumnConfig.columnLayout ?? [], -}; -const populationStatusColumnInfo: ColumnInfo = { - columns: PopulationStatusColumnConfig.columns ?? [], - columnLayout: PopulationStatusColumnConfig.columnLayout ?? [], -}; - -/* ── 엑셀 헤더 검증 ── */ -const validateExcelHeader = (file: File, requiredKeywords: string[]): Promise => { - 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(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); - }); -}; - -/* ══════════════════════════════════════════ - 컴포넌트 -══════════════════════════════════════════ */ const StatisticsPopulationManagement = () => { - const { setMessage: alert } = useAlertStore((state) => state); - const [activeTab, setActiveTab] = useState("domestic"); - const [isLoading, setIsLoading] = useState(false); - - /* ── 내국인 데이터 ── */ - const [domesticLeftRowData, setDomesticLeftRowData] = useState[]>([]); - const [domesticRightRowData, setDomesticRightRowData] = useState[]>([]); - - /* ── 외국인 데이터 ── */ - const [foreignLeftRowData, setForeignLeftRowData] = useState[]>([]); - const [foreignRightRowData, setForeignRightRowData] = useState[]>([]); - - /* ── 행정동갱신 데이터 ── */ - const [renewalReducedRowData, setRenewalReducedRowData] = useState[]>([]); - const [renewalAddedRowData, setRenewalAddedRowData] = useState[]>([]); - - /* ── 행정인구현황 데이터 ── */ - const [statusRowData, setStatusRowData] = useState[]>([]); - const [statusOMale, setStatusOMale] = useState(true); - const [statusOFemale, setStatusOFemale] = useState(true); - const [statusFMale, setStatusFMale] = useState(true); - const [statusFFemale, setStatusFFemale] = useState(true); - - /* ── 행정인구현황 시각화 데이터 ── */ - const [vizYear, setVizYear] = useState(String(new Date().getFullYear() - 1)); - const [vizOMale, setVizOMale] = useState(true); - const [vizOFemale, setVizOFemale] = useState(true); - const [vizFMale, setVizFMale] = useState(true); - const [vizFFemale, setVizFFemale] = useState(true); - const [chartData, setChartData] = useState(null); - - /* ── 상위 코드 다이얼로그 ── */ - const [dialogOpen, setDialogOpen] = useState(false); - const [candidates, setCandidates] = useState([]); - const selectedRowIndexRef = useRef(null); - const selectedTabRef = useRef<"domestic" | "foreign" | "renewal">("domestic"); + /** 검색폼은 모든 탭이 공유. 마지막 조회 조건만 ref로 기억해뒀다가 탭 전환 시 재사용 */ const lastSearchedValuesRef = useRef(null); - const domesticFileInputRef = useRef(null); - const foreignFileInputRef = useRef(null); - /* ════════════════ 내국인 ════════════════ */ - const loadDomesticData = async (values: SearchFormValues, reloadRight = true): Promise => { - const yr = values.year ?? ""; - if (!yr) { alert("연도를 선택해주세요."); return false; } - try { - setIsLoading(true); - const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1"); - if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); - if (reloadRight) { - const rightRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "2"); - if (rightRes.status === "success") setDomesticRightRowData(toGridRows((rightRes.data as object[]) ?? [])); - } - return true; - } catch { - alert("서버 연결 중 오류가 발생했습니다."); - return false; - } finally { - setIsLoading(false); - } - }; + /* ── 탭별 상태/로직은 전부 훅에서 가져온다. 탭이 화면에 보이지 않아도 훅은 항상 호출되므로 + 탭을 왔다갔다 해도 데이터가 유지된다 (원본 컴포넌트와 동일한 동작) ── */ + const domesticTab = usePopulationMappingTab({ + kind: "domestic", + getList: statisticsPpltnApi.getDomesticPopulationList, + uploadExcel: statisticsPpltnApi.uploadDomesticPopulationExcel, + saveFinal: statisticsPpltnApi.saveDomesticPopulationFinal, + getUpperCodesByName: statisticsPpltnApi.getUpperCodesByName, + requiredHeaderKeywords: ["행정기관코드", "행정기관"], + invalidFileMessage: "행정인구(내국인) 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요.", + }); - const handleDomesticFileChange = async (e: React.ChangeEvent) => { - 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, ["행정기관코드", "행정기관"]); - if (!isValidHeader) { alert("행정인구(내국인) 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요."); e.target.value = ""; return; } - const yr = lastSearchedValuesRef.current?.year ?? ""; - if (!yr) { alert("연도를 선택해주세요."); e.target.value = ""; return; } - try { - setIsLoading(true); - const res = await statisticsPpltnApi.uploadDomesticPopulationExcel(file, yr); - if (res.status === "success") { - await loadDomesticData(lastSearchedValuesRef.current!, true); - alert("업로드가 완료되었습니다."); - } else { - alert(res.message || "업로드 중 오류가 발생했습니다."); - } - } catch { - alert("업로드 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - e.target.value = ""; - } - }; + const foreignTab = usePopulationMappingTab({ + kind: "foreign", + getList: async (statsYr, opt1) => { + const res = await statisticsPpltnApi.getForeignPopulationList(statsYr, opt1); + // res.data를 확실하게 ForeignPopulationItem[]로 캐스팅 + return { ...res, data: res.data as ForeignPopulationItem[] }; +}, + uploadExcel: statisticsPpltnApi.uploadForeignPopulationExcel, + saveFinal: statisticsPpltnApi.saveForeignPopulationFinal, + getUpperCodesByName: statisticsPpltnApi.getUpperCodesByName, + requiredHeaderKeywords: ["행정동코드", "행정동명", "외국인수"], + invalidFileMessage: "외국인 인구 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요.", + }); - const handleDomesticFinal = async () => { - const values = lastSearchedValuesRef.current; - const yr = values?.year ?? ""; - if (!yr) { alert("먼저 조회를 진행해주세요."); return; } - if (domesticRightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; } - try { - setIsLoading(true); - const regList = domesticRightRowData - .filter((row) => String(row.mpinCd ?? "").trim() !== "") - .map((row) => ({ - statsYr: yr, - admdstCd: String(row.admdstCd ?? ""), - admdstNm: String(row.admdstNm ?? ""), - subAdmdstCd: String(row.mpinCd ?? ""), - })); - const res = await statisticsPpltnApi.saveDomesticPopulationFinal(yr, regList); - if (res.status === "success") { - const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1"); - if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); - alert("반영이 완료되었습니다."); - } else { - alert(res.message || "반영 중 오류가 발생했습니다."); - } - } catch { - alert("반영 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; + const renewalTab = useAdmdstRenewalTab(); + const statusTab = usePopulationStatusTab(); + const vizTab = usePopulationVizTab(); - /* ════════════════ 외국인 ════════════════ */ - const loadForeignData = async (values: SearchFormValues, reloadRight = true): Promise => { - const yr = values.year ?? ""; - if (!yr) { alert("연도를 선택해주세요."); return false; } - try { - setIsLoading(true); - const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1"); - if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); - if (reloadRight) { - const rightRes = await statisticsPpltnApi.getForeignPopulationList(yr, "2"); - if (rightRes.status === "success") setForeignRightRowData(toGridRows((rightRes.data as object[]) ?? [])); - } - return true; - } catch { - alert("서버 연결 중 오류가 발생했습니다."); - return false; - } finally { - setIsLoading(false); - } - }; + const statsYr = lastSearchedValuesRef.current?.year ?? ""; - const handleForeignFileChange = async (e: React.ChangeEvent) => { - 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, ["행정동코드", "행정동명", "외국인수"]); - if (!isValidHeader) { alert("외국인 인구 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요."); e.target.value = ""; return; } - const yr = lastSearchedValuesRef.current?.year ?? ""; - if (!yr) { alert("연도를 선택해주세요."); e.target.value = ""; return; } - try { - setIsLoading(true); - const res = await statisticsPpltnApi.uploadForeignPopulationExcel(file, yr); - if (res.status === "success") { - await loadForeignData(lastSearchedValuesRef.current!, true); - alert("업로드가 완료되었습니다."); - } else { - alert(res.message || "업로드 중 오류가 발생했습니다."); - } - } catch { - alert("업로드 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - e.target.value = ""; - } - }; - - const handleForeignFinal = async () => { - const values = lastSearchedValuesRef.current; - const yr = values?.year ?? ""; - if (!yr) { alert("먼저 조회를 진행해주세요."); return; } - if (foreignRightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; } - try { - setIsLoading(true); - const regList = foreignRightRowData - .filter((row) => String(row.mpinCd ?? "").trim() !== "") - .map((row) => ({ - statsYr: yr, - admdstCd: String(row.admdstCd ?? ""), - admdstNm: String(row.admdstNm ?? ""), - subAdmdstCd: String(row.mpinCd ?? ""), - })); - const res = await statisticsPpltnApi.saveForeignPopulationFinal(yr, regList); - if (res.status === "success") { - const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1"); - if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); - alert("반영이 완료되었습니다."); - } else { - alert(res.message || "반영 중 오류가 발생했습니다."); - } - } catch { - alert("반영 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; - - /* ════════════════ 행정동갱신 ════════════════ */ - const loadRenewalData = async (values: SearchFormValues): Promise => { - const yr = values.year ?? ""; - if (!yr) { alert("연도를 선택해주세요."); return false; } - try { - setIsLoading(true); - // OPT_1: "1" = 추가된 행정구역, "2" = 감축된 행정구역 - const [addedRes, reducedRes] = await Promise.all([ - statisticsPpltnApi.searchChangedArea(yr, "1"), - statisticsPpltnApi.searchChangedArea(yr, "2"), - ]); - if (addedRes.status === "success") setRenewalAddedRowData(toGridRows((addedRes.data as object[]) ?? [])); - if (reducedRes.status === "success") setRenewalReducedRowData(toGridRows((reducedRes.data as object[]) ?? [])); - return true; - } catch { - alert("서버 연결 중 오류가 발생했습니다."); - return false; - } finally { - setIsLoading(false); - } - }; - - const handleRenewalFinal = async () => { - const values = lastSearchedValuesRef.current; - if (!values?.year) { alert("먼저 조회를 진행해주세요."); return; } - if (renewalReducedRowData.length === 0 && renewalAddedRowData.length === 0) { - alert("반영할 데이터가 없습니다."); - return; - } - try { - setIsLoading(true); - const userInfo = await getUserInfo(); - - // adstDstrNm("시도 구군 동" 형태)을 sido/gugun/adstDong으로 분리 - const addedList = renewalAddedRowData.map((row) => { - const fullNm = String(row.adstDstrNm ?? "").trim(); - const parts = fullNm.split(/\s+/); - const sido = parts[0] ?? ""; - const gugun = parts.length >= 2 ? parts[1] : ""; - const adstDong = parts.length >= 3 ? parts.slice(2).join(" ") : ""; - - return { - adstCd: String(row.adstCd ?? ""), - lawCd: String(row.lawCd ?? ""), - sido, - gugun, - adstDong, - userId: userInfo.userUnqId, - }; - }); - - const reducedList = renewalReducedRowData.map((row) => ({ - adstCd: String(row.adstCd ?? ""), - })); - - const res = await statisticsPpltnApi.saveChangedArea(reducedList, addedList); - if (res.status === "success") { - await loadRenewalData(values); - alert("행정구역 감축 및 추가사항이 성공적으로 반영되었습니다."); - } else { - alert(res.message || "반영 중 오류가 발생했습니다."); - } - } catch { - alert("반영 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; - - /* ════════════════ 행정인구현황 시각화 ════════════════ */ - const handleVizSearch = async (yr: string) => { - if (!yr) { alert("연도를 선택해주세요."); return; } - try { - setIsLoading(true); - const res = await statisticsPpltnApi.getChartDatas1(yr, vizOMale, vizOFemale, vizFMale, vizFFemale); - if (res.status === "success") { - setChartData(res.data as PopulationChartData); - } else { - alert(res.message || "조회 중 오류가 발생했습니다."); - } - } catch { - alert("서버 연결 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; - - const handleVizSelectAll = () => { - setVizOMale(true); - setVizOFemale(true); - setVizFMale(true); - setVizFFemale(true); - }; - - const handleVizDeselectAll = () => { - setVizOMale(false); - setVizOFemale(false); - setVizFMale(false); - setVizFFemale(false); - }; + const isLoading = + (activeTab === "domestic" && domesticTab.isLoading) || + (activeTab === "foreign" && foreignTab.isLoading) || + (activeTab === "renewal" && renewalTab.isLoading) || + (activeTab === "status" && statusTab.isLoading) || + (activeTab === "statusViz" && vizTab.isLoading); /* ════════════════ 공통 ════════════════ */ const handleSearch = async (values: SearchFormValues) => { lastSearchedValuesRef.current = values; - if (activeTab === "domestic") { - const yr = values.year ?? ""; - try { - setIsLoading(true); - const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1"); - if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); - if (domesticRightRowData.length === 0) { - const rightRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "2"); - if (rightRes.status === "success") setDomesticRightRowData(toGridRows((rightRes.data as object[]) ?? [])); - } - } catch { - alert("서버 연결 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - } else if (activeTab === "foreign") { - const yr = values.year ?? ""; - try { - setIsLoading(true); - const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1"); - if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? [])); - if (foreignRightRowData.length === 0) { - const rightRes = await statisticsPpltnApi.getForeignPopulationList(yr, "2"); - if (rightRes.status === "success") setForeignRightRowData(toGridRows((rightRes.data as object[]) ?? [])); - } - } catch { - alert("서버 연결 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - } else if (activeTab === "renewal") { - await loadRenewalData(values); - } else if (activeTab === "status") { - const yr = values.year ?? ""; - if (!yr) { alert("연도를 선택해주세요."); return; } - if (!statusOMale && !statusOFemale && !statusFMale && !statusFFemale) { - alert("체크 박스를 모두 해제하신 상태에서는\n조회가 불가능합니다.\n(최소 1개 이상의 체크 박스 선택 후\n조회를 진행해주세요.)"); - return; - } - try { - setIsLoading(true); - const res = await statisticsPpltnApi.searchPopByEachCondition( - yr, statusOMale, statusOFemale, statusFMale, statusFFemale, - ); - if (res.status === "success") { - setStatusRowData(toGridRows((res.data as object[]) ?? [])); - } else { - alert(res.message || "조회 중 오류가 발생했습니다."); - } - } catch { - alert("서버 연결 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - } else if (activeTab === "statusViz") { - const yr = values.year ?? ""; - setVizYear(yr); - await handleVizSearch(yr); - } + const yr = values.year ?? ""; + + if (activeTab === "domestic") await domesticTab.search(yr); + else if (activeTab === "foreign") await foreignTab.search(yr); + else if (activeTab === "renewal") await renewalTab.loadAll(yr); + else if (activeTab === "status") await statusTab.search(yr); + else if (activeTab === "statusViz") await vizTab.search(yr); }; const handleReset = () => { - setDomesticLeftRowData([]); - setDomesticRightRowData([]); - setForeignLeftRowData([]); - setForeignRightRowData([]); - setRenewalReducedRowData([]); - setRenewalAddedRowData([]); - setStatusRowData([]); - setStatusOMale(true); - setStatusOFemale(true); - setStatusFMale(true); - setStatusFFemale(true); - setVizYear(String(new Date().getFullYear() - 1)); - setVizOMale(true); - setVizOFemale(true); - setVizFMale(true); - setVizFFemale(true); - setChartData(null); + domesticTab.reset(); + foreignTab.reset(); + renewalTab.reset(); + statusTab.reset(); + vizTab.reset(); setActiveTab("domestic"); lastSearchedValuesRef.current = null; }; const handleActiveTabChange = async (tabKey: string) => { setActiveTab(tabKey); + const values = lastSearchedValuesRef.current; if (!values) return; - if (tabKey === "domestic" && domesticLeftRowData.length === 0) await loadDomesticData(values); - if (tabKey === "foreign" && foreignLeftRowData.length === 0) await loadForeignData(values); - if (tabKey === "renewal" && renewalReducedRowData.length === 0 && renewalAddedRowData.length === 0) await loadRenewalData(values); + const yr = values.year ?? ""; + + if (tabKey === "domestic") await domesticTab.loadOnActivate(yr); + else if (tabKey === "foreign") await foreignTab.loadOnActivate(yr); + else if (tabKey === "renewal") await renewalTab.loadOnActivate(yr); + // status / statusViz는 원본에서도 탭 전환 시 자동 재조회하지 않음 (그대로 유지) }; - /* ── 우측 행 클릭 → 상위 코드 후보 조회 ── */ - const handleRightRowClick = async ( - rowData: Record, - rowIndex: number, - tab: "domestic" | "foreign", - ) => { - const yr = lastSearchedValuesRef.current?.year ?? ""; - const admdstNm = String(rowData.admdstNm ?? ""); - if (!admdstNm) { alert("행정구역명이 없습니다."); return; } - try { - setIsLoading(true); - const res = await statisticsPpltnApi.getUpperCodesByName(yr, admdstNm); - if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) { - setCandidates(res.data); - selectedRowIndexRef.current = rowIndex; - selectedTabRef.current = tab; - setDialogOpen(true); - } else { - alert("해당 지역명에 대한 상위 코드 후보가 없습니다."); - } - } catch { - alert("상위 코드 조회 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; - - /* ── 행정동갱신 우측 행 클릭 → 법정동코드 후보 조회 ── */ - const handleRenewalRowClick = async ( - rowData: Record, - rowIndex: number, - ) => { - const yr = lastSearchedValuesRef.current?.year ?? ""; - const adstDstrNm = String(rowData.adstDstrNm ?? ""); - if (!adstDstrNm) { alert("행정구역명이 없습니다."); return; } - try { - setIsLoading(true); - const res = await statisticsPpltnApi.getRenewalUpperCodesByName(yr, adstDstrNm); - if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) { - setCandidates(res.data); - selectedRowIndexRef.current = rowIndex; - selectedTabRef.current = "renewal"; - setDialogOpen(true); - } else { - alert("해당 지역명에 대한 법정동코드 후보가 없습니다."); - } - } catch { - alert("법정동코드 조회 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - } - }; - - /* ── 다이얼로그 코드 선택 → 업데이트 ── */ - const handleCodeSelect = (item: UpperCodeCandidateItem) => { - const rowIndex = selectedRowIndexRef.current; - if (rowIndex === null) return; - - if (selectedTabRef.current === "renewal") { - // 행정동갱신: 법정동코드/법정동명 업데이트 - setRenewalAddedRowData((prev) => - prev.map((row, idx) => - idx === rowIndex - ? { ...row, lawCd: item.adstCd, lawNm: item.adstDstrNm } - : row - ) - ); - } else { - // 내국인/외국인: mpinCd 업데이트 - const setter = selectedTabRef.current === "domestic" ? setDomesticRightRowData : setForeignRightRowData; - setter((prev) => - prev.map((row, idx) => - idx === rowIndex ? { ...row, mpinCd: item.adstCd } : row - ) - ); - } - selectedRowIndexRef.current = null; - }; - - /* ══════════════════════ 렌더 ══════════════════════ */ return (
- - + void domesticTab.handleFileChange(e, statsYr)} + /> + void foreignTab.handleFileChange(e, statsYr)} + />
+ {activeTab === "status" && (
)} + {activeTab === "statusViz" && (
- -
@@ -634,229 +235,49 @@ const StatisticsPopulationManagement = () => { )}
- void handleActiveTabChange(v)} className="w-full" variant="default"> + void handleActiveTabChange(v)} + className="w-full" + variant="default" + > {TAB_LIST.map((tab) => ( - {tab.label} + + {tab.label} + ))} - {/* ── 내국인 탭 ── */} -
-
-
- - 엑셀 업로드 자료 (내국인 전체 리스트) - -
-
- -
- -
-
-
- - 미매핑 리스트 (작년 대비) - -
-
- -
- { - const rowIndex = domesticRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd); - void handleRightRowClick(rowData, rowIndex, "domestic"); - }} - /> -
-
-
- - {/* ── 외국인 탭 ── */} - -
-
-
- - 엑셀 업로드 자료 (외국인 전체 리스트) - -
- - {/* pr-8 (오른쪽 패딩)을 추가하여 버튼을 왼쪽으로 밀어냅니다 */} -
- -
- -
-
-
- - 미매핑 리스트 (작년 대비) - -
-
- -
- { - const rowIndex = foreignRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd); - void handleRightRowClick(rowData, rowIndex, "foreign"); - }} - /> -
-
-
- - -
-
-
- - 감축된 행정구역(범위:동읍면) 자료 - -
-
- -
-
-
- - 추가된 행정구역 자료 - -
-
- -
- { - const rowIndex = renewalAddedRowData.findIndex((r) => r.adstCd === rowData.adstCd); - void handleRenewalRowClick(rowData, rowIndex); - }} - /> -
-
- - - {/* 그리드 라벨 */} -
- - 행정 인구 현황 - -
- - domesticTab.fileInputRef.current?.click()} />
- {/* ── 행정인구현황 시각화 탭 ── */} - - - 행정인구현황 시각화 (기준연도 ~ 과거 자료(최대 5년) / *단위: 명) - + + foreignTab.fileInputRef.current?.click()} + /> + -
- -
+ + + + + + + + + +
- -
); }; diff --git a/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx index 52355304f..ced0faf4d 100644 --- a/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx +++ b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx @@ -7,25 +7,30 @@ import { DialogHeader, DialogTitle, } from "@common/components"; -import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; interface UpperCodeSelectDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - candidates: UpperCodeCandidateItem[]; - onSelect: (item: UpperCodeCandidateItem) => void; + candidates: Record[]; + codeField: string; + nameField: string; + onSelect: (item: Record) => void; + extraFields?: { field: string; header: string }[]; } export const UpperCodeSelectDialog = ({ open, onOpenChange, candidates, + codeField, + nameField, + extraFields, onSelect, }: UpperCodeSelectDialogProps) => { const [selectedCode, setSelectedCode] = useState(null); const handleSelect = () => { - const item = candidates.find((c) => c.adstCd === selectedCode); + const item = candidates.find((c) => c[codeField] === selectedCode); if (!item) return; onSelect(item); onOpenChange(false); @@ -43,56 +48,62 @@ export const UpperCodeSelectDialog = ({ 상위 코드 리스트 - - {/* 테이블 */} -
- - - - - - - -
- 코드 - - 동명 -
- {/* 스크롤 영역 분리 */} -
- - - {candidates.length === 0 ? ( - - - - ) : ( - candidates.map((item) => ( - setSelectedCode(item.adstCd)} - className={`cursor-pointer border-b border-gray-200 transition-colors - ${selectedCode === item.adstCd - ? "bg-blue-100" - : "hover:bg-gray-50" - }`} - > - - - - )) - )} - -
- 후보 코드가 없습니다. -
- {item.adstCd} - - {item.adstDstrNm} -
-
-
- +
+ + + + + + {extraFields?.map((f) => ( + + ))} + + +
+ 코드 + + 동명 + + {f.header} +
+
+ + + {candidates.length === 0 ? ( + + + + ) : ( + candidates.map((item,index) => ( + setSelectedCode(item[codeField] ?? "")} + className={`cursor-pointer border-b border-gray-200 transition-colors + ${selectedCode === item[codeField] + ? "bg-blue-100" + : "hover:bg-gray-50" + }`} + > + + + {extraFields?.map((f) => ( + + ))} + + )) + )} + +
+ 후보 코드가 없습니다. +
+ {item[codeField]} + + {item[nameField]} + + {item[f.field]} +
+
+