diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index e51214178..3635d9f99 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -720,6 +720,7 @@ const DataGrid = ({ onCellEdited, onRowClick, onDataCellClick, + onCellDblClick, onItemChecked, onItemAllChecked, onScrollToBottom, @@ -728,6 +729,7 @@ const DataGrid = ({ idFieldName = "id", enableSort = false, gridHeight = 55, // vh 단위 + onRowDblClick, headerHeight = 60, fitStyle = GridFitStyle.EVEN, }: DataGridProps) => { @@ -766,6 +768,7 @@ const DataGrid = ({ onCellEditedRef.current = onCellEdited; onDataCellClickRef.current = onDataCellClick; + // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { if (!gridRef.current?.gridView || highlightedRowId == null) return; @@ -781,7 +784,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(() => { const gv = gridRef.current?.gridView; if (!gv || !cellStyleCallback) return; 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/PopulationStatusChart.tsx b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx new file mode 100644 index 000000000..1b7198135 --- /dev/null +++ b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx @@ -0,0 +1,106 @@ +import { Chart } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + Title, + Tooltip, + Legend, +} from "chart.js"; +import type { ChartOptions } from "chart.js"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend); + +// 차트 설정 외부 정의 (Mixed 타입 명시) +const options: ChartOptions<"bar" | "line"> = { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { stacked: true }, + y: { + stacked: true, + beginAtZero: true, + title: { display: true, text: "인구수(명)" }, + }, + y1: { + position: "right", + beginAtZero: true, + grid: { drawOnChartArea: false }, + title: { display: true, text: "총 인구(명)" }, + }, + }, + plugins: { + legend: { position: "bottom" }, + }, +}; + +export type PopulationChartData = { + X_AXIS: string[]; + TOT: number[]; + ORIGIN_MALE: (number | null)[]; + ORIGIN_FEMALE: (number | null)[]; + FOREIGN_MALE: (number | null)[]; + FOREIGN_FEMALE: (number | null)[]; +}; + +type Props = { + chartData: PopulationChartData | null; +}; + +const SERIES_META = [ + { key: "ORIGIN_MALE", name: "내국인 남성", color: "#3B82F6" }, + { key: "ORIGIN_FEMALE", name: "내국인 여성", color: "#EC4899" }, + { key: "FOREIGN_MALE", name: "외국인 남성", color: "#10B981" }, + { key: "FOREIGN_FEMALE", name: "외국인 여성", color: "#F59E0B" }, +] as const; + +const PopulationStatusChart = ({ chartData }: Props) => { + if (!chartData || chartData.X_AXIS.length === 0) { + return ( +
+ 조회된 데이터가 없습니다. 조회 버튼을 눌러주세요. +
+ ); + } + + const data = { + labels: chartData.X_AXIS.map((yr) => `${yr}년`), + datasets: [ + ...SERIES_META + .filter((s) => chartData[s.key]?.some((v) => v !== null)) + .map((s) => ({ + type: "bar" as const, + label: s.name, + data: chartData[s.key].map((v) => v ?? 0), + backgroundColor: s.color, + stack: "stack1", + })), + { + type: "line" as const, + label: "총 인구", + data: chartData.TOT, + borderColor: "#1F2937", + backgroundColor: "#1F2937", + borderDash: [5, 5], + tension: 0.1, + yAxisID: "y1", + }, + ], + }; + + return ( +
+ {/* 제네릭 타입을 통해 Mixed Chart 지원, 부모 컨테이너 크기에 맞춰 반응형으로 리사이즈됨 */} + + type="bar" + data={data} + options={options} + /> +
+ ); +}; + +export default PopulationStatusChart; 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 c0c5e0de1..c33feff95 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -1,11 +1,285 @@ +import { useRef, useState } from "react"; + +import { Button, LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; import SearchForm from "@/components/module/SearchForm"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; +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: "statusViz", label: "행정인구현황 시각화" }, +]; const StatisticsPopulationManagement = () => { + const [activeTab, setActiveTab] = useState("domestic"); + + /** 검색폼은 모든 탭이 공유. 마지막 조회 조건만 ref로 기억해뒀다가 탭 전환 시 재사용 */ + const lastSearchedValuesRef = useRef(null); + + /* ── 탭별 상태/로직은 전부 훅에서 가져온다. 탭이 화면에 보이지 않아도 훅은 항상 호출되므로 + 탭을 왔다갔다 해도 데이터가 유지된다 (원본 컴포넌트와 동일한 동작) ── */ + const domesticTab = usePopulationMappingTab({ + kind: "domestic", + getList: statisticsPpltnApi.getDomesticPopulationList, + uploadExcel: statisticsPpltnApi.uploadDomesticPopulationExcel, + saveFinal: statisticsPpltnApi.saveDomesticPopulationFinal, + getUpperCodesByName: statisticsPpltnApi.getUpperCodesByName, + requiredHeaderKeywords: ["행정기관코드", "행정기관"], + invalidFileMessage: "행정인구(내국인) 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요.", + }); + + 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 renewalTab = useAdmdstRenewalTab(); + const statusTab = usePopulationStatusTab(); + const vizTab = usePopulationVizTab(); + + const statsYr = lastSearchedValuesRef.current?.year ?? ""; + + 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; + 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 = () => { + 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; + 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는 원본에서도 탭 전환 시 자동 재조회하지 않음 (그대로 유지) + }; + return ( - <> - - +
+ void domesticTab.handleFileChange(e, statsYr)} + /> + void foreignTab.handleFileChange(e, statsYr)} + /> + +
+ + + {activeTab === "status" && ( +
+ + + + +
+ )} + + {activeTab === "statusViz" && ( +
+ + + + + + +
+ )} +
+ + {isLoading && ( +
+
+ +
+
+ )} + +
+ void handleActiveTabChange(v)} + className="w-full" + variant="default" + > + + {TAB_LIST.map((tab) => ( + + {tab.label} + + ))} + + + + domesticTab.fileInputRef.current?.click()} + /> + + + + foreignTab.fileInputRef.current?.click()} + /> + + + + + + + + + + + + + + +
+
); }; -export default StatisticsPopulationManagement; +export default StatisticsPopulationManagement; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx new file mode 100644 index 000000000..ced0faf4d --- /dev/null +++ b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@common/components"; + +interface UpperCodeSelectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => 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[codeField] === selectedCode); + if (!item) return; + onSelect(item); + onOpenChange(false); + setSelectedCode(null); + }; + + const handleClose = () => { + onOpenChange(false); + setSelectedCode(null); + }; + + return ( + + + + 상위 코드 리스트 + +
+ + + + + + {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]} +
+
+
+ + + + +
+
+ ); +}; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/hooks/useAdmdstRenewalTab.ts b/apps/st/src/components/statistics/management/hooks/useAdmdstRenewalTab.ts new file mode 100644 index 000000000..a1fecef7b --- /dev/null +++ b/apps/st/src/components/statistics/management/hooks/useAdmdstRenewalTab.ts @@ -0,0 +1,161 @@ +import { useRef, useState } from "react"; + +import { useAlertStore } from "@/shared/lib"; +import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; +import { toGridRows } from "@/components/util/grid/gridFunction"; +// TODO: 원본 파일은 `../../../../../../packages/common/components/src/api` (6단계 상위) 였음. +// 이 훅 파일은 원본 컴포넌트보다 한 단계 더 깊은 폴더(hooks/)에 위치하므로 +// 실제 프로젝트에 옮길 때 상대경로 깊이를 다시 맞춰줘야 함 (혹은 @common/components에 getUserInfo가 +// re-export 되어 있다면 그쪽으로 교체). +import { getUserInfo } from "../../../../../../../packages/common/components/src/api"; + +import { useAsyncAction } from "./useAsyncAction"; +import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; +import type { + AdmdstRenewalAddedItem, + AdmdstRenewalAddedSaveItem, + AdmdstRenewalReducedItem, + AdmdstRenewalReducedSaveItem, +} from "@/components/statistics/management/types/population-tab-types"; + +export function useAdmdstRenewalTab() { + const { setMessage: alert } = useAlertStore((state) => state); + const { isLoading, run } = useAsyncAction(alert); + + const [reducedRowData, setReducedRowData] = useState([]); + const [addedRowData, setAddedRowData] = useState([]); + + const [dialogOpen, setDialogOpen] = useState(false); + const [candidates, setCandidates] = useState([]); + const selectedRowIndexRef = useRef(null); + + /** OPT_1: "1" = 추가된 행정구역, "2" = 감축된 행정구역 (원본 그대로) */ + const loadAll = async (statsYr: string): Promise => { + if (!statsYr) { alert("연도를 선택해주세요."); return false; } + const result = await run(async () => { + const [addedRes, reducedRes] = await Promise.all([ + statisticsPpltnApi.searchChangedArea(statsYr, "1"), + statisticsPpltnApi.searchChangedArea(statsYr, "2"), + ]); + if (addedRes.status === "success") { + setAddedRowData(toGridRows((addedRes.data as object[]) ?? []) as unknown as AdmdstRenewalAddedItem[]); + } + if (reducedRes.status === "success") { + setReducedRowData(toGridRows((reducedRes.data as object[]) ?? []) as unknown as AdmdstRenewalReducedItem[]); + } + return true; + }); + return result ?? false; + }; + + /** 탭 전환 시 - 아직 한 번도 로드되지 않았을 때만 로드 */ + const loadOnActivate = async (statsYr: string) => { + if (reducedRowData.length === 0 && addedRowData.length === 0) await loadAll(statsYr); + }; + + const handleFinal = async (statsYr: string) => { + if (!statsYr) { alert("먼저 조회를 진행해주세요."); return; } + if (reducedRowData.length === 0 && addedRowData.length === 0) { + alert("반영할 데이터가 없습니다."); + return; + } + + await run(async () => { + const userInfo = await getUserInfo(); +const addedList: AdmdstRenewalAddedSaveItem[] = addedRowData + .filter((row) => { + const lawCd = String(row.lawCd ?? "").trim(); + return lawCd !== "" && lawCd !== "null"; + }) + .map((row) => ({ + adstCd: String(row.adstCd ?? ""), + lawCd: String(row.lawCd ?? ""), + adstDstrNm: String(row.adstDstrNm ?? ""), + rgnCd: String(row.rgnCd ?? ""), + lv: String(row.lv ?? ""), + sido: String(row.sido ?? ""), + gugun: String(row.gugun ?? ""), + adstDong: String(row.adstDong ?? ""), + userId: userInfo.userUnqId, + })); + +const reducedList: AdmdstRenewalReducedSaveItem[] = reducedRowData.map((row) => ({ + adstCd: String(row.adstCd ?? ""), +})); + +console.log("reducedList:", reducedList); +console.log("addedList:", addedList); +console.log("reducedList 이상한 것:", reducedList.filter(r => r.adstCd.length !== 10)); + const res = await statisticsPpltnApi.saveChangedArea(reducedList, addedList); + if (res.status === "success") { + await loadAll(statsYr); + alert("행정구역 감축 및 추가사항이 성공적으로 반영되었습니다."); + } else { + alert(res.message || "반영 중 오류가 발생했습니다."); + } + }); + + }; + + const handleRowClick = async (rowData: AdmdstRenewalAddedItem, statsYr: string) => { + const adstCd = String(rowData.adstCd ?? ""); + if (!adstCd) { alert("행정동코드가 없습니다."); return; } + + const parts = adstCd.trim().split(/\s+/); + const dongNm = parts[parts.length - 1] ?? adstCd; + + await run( + async () => { + const res = await statisticsPpltnApi.getLawCdsByName(dongNm); + if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) { + setCandidates(res.data); + const rowIndex = addedRowData.findIndex((r) => r.adstCd === rowData.adstCd); + console.log("rowData.adstCd:", rowData.adstCd); // ← 여기 + console.log("rowIndex:", rowIndex); // ← 여기 + selectedRowIndexRef.current = rowIndex; + setDialogOpen(true); + } else { + alert("해당 지역명에 대한 법정동코드 후보가 없습니다."); + } + }, + { errorMessage: "법정동코드 조회 중 오류가 발생했습니다." }, + ); +}; + const handleCodeSelect = (item: Record) => { + const rowIndex = selectedRowIndexRef.current; + if (rowIndex === null) return; + setAddedRowData((prev) => + prev.map((row, idx) => + idx === rowIndex + ? { ...row, lawCd: item["lawCd"] ?? "", lawNm: item["lawNm"] ?? "", rgnCd: item["rgnCd"] ?? "", lv: item["lv"] ?? "" } + : row, + ), + ); + selectedRowIndexRef.current = null; +}; + + const reset = () => { + setReducedRowData([]); + setAddedRowData([]); + setDialogOpen(false); + setCandidates([]); + selectedRowIndexRef.current = null; + }; + + return { + isLoading, + reducedRowData, + addedRowData, + setReducedRowData, + setAddedRowData, + dialogOpen, + setDialogOpen, + candidates, + loadAll, + loadOnActivate, + handleFinal, + handleRowClick, + handleCodeSelect, + reset, + }; +} \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/hooks/useAsyncAction.ts b/apps/st/src/components/statistics/management/hooks/useAsyncAction.ts new file mode 100644 index 000000000..fc03df2a0 --- /dev/null +++ b/apps/st/src/components/statistics/management/hooks/useAsyncAction.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from "react"; + +type AsyncActionOptions = { + /** onError가 없을 때 사용할 기본 에러 메시지 */ + errorMessage?: string; + /** 커스텀 에러 처리가 필요할 때 - 지정하면 errorMessage 대신 이 콜백이 호출됨 */ + onError?: (error: unknown) => void; +}; + +/** + * 탭별 hook(usePopulationMappingTab, useAdmdstRenewalTab 등)에서 + * 반복되던 `setIsLoading(true) → try → catch(alert) → finally(setIsLoading(false))` + * 패턴을 공통화한 훅. + * + * notify: 실패 시 사용자에게 보여줄 알럿 함수 (useAlertStore의 setMessage를 그대로 넘기면 됨) + */ +export function useAsyncAction(notify: (message: string) => void) { + const [isLoading, setIsLoading] = useState(false); + + const run = useCallback( + async (action: () => Promise, options: AsyncActionOptions = {}): Promise => { + const { errorMessage = "서버 연결 중 오류가 발생했습니다.", onError } = options; + try { + setIsLoading(true); + return await action(); + } catch (error) { + if (onError) { + onError(error); + } else { + notify(errorMessage); + } + return undefined; + } finally { + setIsLoading(false); + } + }, + [notify], + ); + + return { isLoading, run }; +} \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/hooks/usePopulationMappingTab.ts b/apps/st/src/components/statistics/management/hooks/usePopulationMappingTab.ts new file mode 100644 index 000000000..5874e9038 --- /dev/null +++ b/apps/st/src/components/statistics/management/hooks/usePopulationMappingTab.ts @@ -0,0 +1,200 @@ +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 => { + 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); + }); +}; + +/** + * 내국인/외국인 탭이 공유하는 "좌측 전체 리스트 + 우측 미매핑 리스트" 패턴 훅. + * 두 탭은 API 함수와 필드명만 다를 뿐 동작이 완전히 동일해서 config로 주입받는다. + */ +export function usePopulationMappingTab( + config: PopulationMappingApiConfig, +) { + const { setMessage: alert } = useAlertStore((state) => state); + const { isLoading, run } = useAsyncAction(alert); + + const [leftRowData, setLeftRowData] = useState([]); + const [rightRowData, setRightRowData] = useState([]); + + const [dialogOpen, setDialogOpen] = useState(false); + const [candidates, setCandidates] = useState([]); + const selectedRowIndexRef = useRef(null); + + const fileInputRef = useRef(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 => { + 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 => { + 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, 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) => { + 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, + }; +} \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/hooks/usePopulationStatusTab.ts b/apps/st/src/components/statistics/management/hooks/usePopulationStatusTab.ts new file mode 100644 index 000000000..e4e13b46d --- /dev/null +++ b/apps/st/src/components/statistics/management/hooks/usePopulationStatusTab.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; + +import { useAlertStore } from "@/shared/lib"; +import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; +import { toGridRows } from "@/components/util/grid/gridFunction"; + +import { useAsyncAction } from "./useAsyncAction"; +import type { PopulationStatusItem } from "../types/population-tab-types"; + +export function usePopulationStatusTab() { + const { setMessage: alert } = useAlertStore((state) => state); + const { isLoading, run } = useAsyncAction(alert); + + const [rowData, setRowData] = useState([]); + + const [includeOriginMale, setIncludeOriginMale] = useState(true); + const [includeOriginFemale, setIncludeOriginFemale] = useState(true); + const [includeForeignMale, setIncludeForeignMale] = useState(true); + const [includeForeignFemale, setIncludeForeignFemale] = useState(true); + + const search = async (statsYr: string): Promise => { + if (!statsYr) { alert("연도를 선택해주세요."); return false; } + if (!includeOriginMale && !includeOriginFemale && !includeForeignMale && !includeForeignFemale) { + alert( + "체크 박스를 모두 해제하신 상태에서는\n조회가 불가능합니다.\n(최소 1개 이상의 체크 박스 선택 후\n조회를 진행해주세요.)", + ); + return false; + } + + const result = await run(async () => { + const res = await statisticsPpltnApi.searchPopByEachCondition( + statsYr, + includeOriginMale, + includeOriginFemale, + includeForeignMale, + includeForeignFemale, + ); + if (res.status === "success") { + setRowData(toGridRows((res.data as object[]) ?? []) as unknown as PopulationStatusItem[]); + return true; + } + alert(res.message || "조회 중 오류가 발생했습니다."); + return false; + }); + return result ?? false; + }; + + const reset = () => { + setRowData([]); + setIncludeOriginMale(true); + setIncludeOriginFemale(true); + setIncludeForeignMale(true); + setIncludeForeignFemale(true); + }; + + return { + isLoading, + rowData, + setRowData, + includeOriginMale, + setIncludeOriginMale, + includeOriginFemale, + setIncludeOriginFemale, + includeForeignMale, + setIncludeForeignMale, + includeForeignFemale, + setIncludeForeignFemale, + search, + reset, + }; +} \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/hooks/usePopulationVizTab.ts b/apps/st/src/components/statistics/management/hooks/usePopulationVizTab.ts new file mode 100644 index 000000000..7490d89bf --- /dev/null +++ b/apps/st/src/components/statistics/management/hooks/usePopulationVizTab.ts @@ -0,0 +1,84 @@ +import { useState } from "react"; + +import { useAlertStore } from "@/shared/lib"; +import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; + +import { useAsyncAction } from "./useAsyncAction"; +import type { PopulationChartData } from "../PopulationStatusChart"; + +export function usePopulationVizTab() { + const { setMessage: alert } = useAlertStore((state) => state); + const { isLoading, run } = useAsyncAction(alert); + + const [year, setYear] = useState(String(new Date().getFullYear() - 1)); + + const [includeOriginMale, setIncludeOriginMale] = useState(true); + const [includeOriginFemale, setIncludeOriginFemale] = useState(true); + const [includeForeignMale, setIncludeForeignMale] = useState(true); + const [includeForeignFemale, setIncludeForeignFemale] = useState(true); + + const [chartData, setChartData] = useState(null); + + const search = async (statsYr: string): Promise => { + if (!statsYr) { alert("연도를 선택해주세요."); return false; } + setYear(statsYr); + + const result = await run(async () => { + const res = await statisticsPpltnApi.getChartDatas1( + statsYr, + includeOriginMale, + includeOriginFemale, + includeForeignMale, + includeForeignFemale, + ); + if (res.status === "success") { + setChartData(res.data as PopulationChartData); + return true; + } + alert(res.message || "조회 중 오류가 발생했습니다."); + return false; + }); + return result ?? false; + }; + + const selectAll = () => { + setIncludeOriginMale(true); + setIncludeOriginFemale(true); + setIncludeForeignMale(true); + setIncludeForeignFemale(true); + }; + + const deselectAll = () => { + setIncludeOriginMale(false); + setIncludeOriginFemale(false); + setIncludeForeignMale(false); + setIncludeForeignFemale(false); + }; + + const reset = () => { + setYear(String(new Date().getFullYear() - 1)); + setIncludeOriginMale(true); + setIncludeOriginFemale(true); + setIncludeForeignMale(true); + setIncludeForeignFemale(true); + setChartData(null); + }; + + return { + isLoading, + year, + chartData, + includeOriginMale, + setIncludeOriginMale, + includeOriginFemale, + setIncludeOriginFemale, + includeForeignMale, + setIncludeForeignMale, + includeForeignFemale, + setIncludeForeignFemale, + search, + selectAll, + deselectAll, + reset, + }; +} \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/lib/gridCast.ts b/apps/st/src/components/statistics/management/lib/gridCast.ts new file mode 100644 index 000000000..438e45542 --- /dev/null +++ b/apps/st/src/components/statistics/management/lib/gridCast.ts @@ -0,0 +1,27 @@ +import type { Dispatch, SetStateAction } from "react"; + +export type GridRow = Record; + +/** + * DataGrid가 제네릭이 아니라 rowData: Record[] 를 그대로 요구함 + * (실제 타입 에러로 확인됨: ts(2322), "missing properties from PopulationStatusItem"). + * + * 훅/탭 내부에서는 명시적 도메인 타입(PopulationStatusItem 등)을 그대로 쓰고, + * DataGrid에 넘기는 이 경계에서만 캐스팅한다. 내부 로직의 타입 안정성은 유지하면서 + * 외부 라이브러리(DataGrid)의 느슨한 타입과의 경계만 명시적으로 표시하는 용도. + */ +export function asGridRowData(data: T[]): GridRow[] { + return data as unknown as GridRow[]; +} + +export function asGridRowSetter(setter: Dispatch>): Dispatch> { + return setter as unknown as Dispatch>; +} + +/** + * DataGrid의 onRowClick 등에서 넘어오는 GridRow(Record)를 + * 도메인 타입으로 되돌릴 때 사용. `as unknown as T`를 매번 직접 쓰지 않도록 모아둠. + */ +export function fromGridRow(row: GridRow): T { + return row as unknown as T; +} \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/types/population-tab-types.ts b/apps/st/src/components/statistics/management/types/population-tab-types.ts new file mode 100644 index 000000000..233fba2d1 --- /dev/null +++ b/apps/st/src/components/statistics/management/types/population-tab-types.ts @@ -0,0 +1,116 @@ +import type { ApiResponse } from "@/entities/statistics/model/statistics-interface"; +import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; + +/* ══════════════════════════════════════════════════ + 외국인 인구 관련 타입 + (내국인 쪽 DomesticPopulationItem과 필드명만 다르고 구조는 동일) +══════════════════════════════════════════════════ */ + +export interface ForeignPopulationItem { + statsYr: string; + admdstCd: string; + admdstNm: string; + frgnrWholPpltnCnt: number; + frgnrMlPpltnCnt: number; + frgnrFmlPpltnCnt: number; + /** 미매핑 리스트(우측) 전용 - 매핑 대상 행정구역코드(상위코드) */ + mpinCd: string; +} + +export type ForeignPopulationListResponse = ApiResponse; + +/* ══════════════════════════════════════════════════ + 행정동갱신 관련 타입 +══════════════════════════════════════════════════ */ + +export interface AdmdstRenewalReducedItem { + adstDstrNm: string; + adstCd: string; +} + +export interface AdmdstRenewalAddedItem { + adstDstrNm: string; + adstCd: string; + lawCd: string; + lawNm: string; + rgnCd?: string; + lv?: string; + sido?: string; // ← 추가 + gugun?: string; // ← 추가 + adstDong?: string; // ← 추가 +} + +/** 추가된 행정구역 반영 시 서버로 보내는 단건 페이로드 */ +export interface AdmdstRenewalAddedSaveItem { + adstCd: string; + lawCd: string; + adstDstrNm: string; + sido: string; + gugun: string; + adstDong: string; + userId: string; + rgnCd: string; + lv: string; +} + +/** 감축된 행정구역 반영 시 서버로 보내는 단건 페이로드 */ +export interface AdmdstRenewalReducedSaveItem { + adstCd: string; +} + +/* ══════════════════════════════════════════════════ + 행정인구현황 관련 타입 +══════════════════════════════════════════════════ */ + +export interface PopulationStatusItem { + statsYr: string; + admdstCd: string; + admdstNm: string; + wholPpltnCnt: number; + domplWholPpltnCnt: number; + domplMlPpltnCnt: number; + domplFmlPpltnCnt: number; + frgnrWholPpltnCnt: number; + frgnrMlPpltnCnt: number; + frgnrFmlPpltnCnt: number; +} + +export type PopulationStatusListResponse = ApiResponse; + +/* ══════════════════════════════════════════════════ + 내국인/외국인 공용 매핑 탭(좌:전체 리스트, 우:미매핑 리스트) 공통 타입 +══════════════════════════════════════════════════ */ + +export type MappingTabKind = "domestic" | "foreign"; + +/** 반영(최종 매핑 저장) 요청 시 서버로 보내는 단건 페이로드 - 내국인/외국인 공통 */ +export interface PopulationMappingRegItem { + statsYr: string; + admdstCd: string; + admdstNm: string; + subAdmdstCd: string; +} + +/** 좌/우 그리드 행이 공통으로 가져야 하는 최소 필드 */ +export interface PopulationMappingRow { + admdstCd: string; + admdstNm: string; + /** 우측(미매핑 리스트) 전용 필드. 좌측 데이터에는 존재하지 않을 수 있음 */ + mpinCd?: string; +} + +/** usePopulationMappingTab에 주입하는 API/검증 설정 - 내국인/외국인 탭이 이 설정만 다르게 넘김 */ +export interface PopulationMappingApiConfig { + kind: MappingTabKind; + /** opt1: "1" = 전체 리스트(좌측), "2" = 미매핑 리스트(우측) */ + getList: (statsYr: string, opt1: "1" | "2") => Promise>; + uploadExcel: (file: File, statsYr: string) => Promise>; + saveFinal: (statsYr: string, regList: PopulationMappingRegItem[]) => Promise>; + getUpperCodesByName: (statsYr: string, admdstNm: string, admdstCd: string) => Promise>; + /** 업로드된 엑셀 헤더에 반드시 포함되어야 하는 키워드 목록 */ + requiredHeaderKeywords: string[]; + /** 헤더 검증 실패 시 노출할 안내 메시지 */ + invalidFileMessage: string; +} + +export type { UpperCodeCandidateItem }; \ No newline at end of file diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index 1c9fc9bae..d3608ad3a 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -395,6 +395,8 @@ export type DataGridProps = { /** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */ getGridRef?: React.MutableRefObject; onRowClick?: (rowData: Record) => void; // 행 클릭 이벤트 + onRowDblClick?: (rowData: Record) => void; // ← 추가 + onCellDblClick?: DataCellClickCallback; /** 데이터 셀 클릭 시 행 + 클릭한 필드명 전달 (`onRowClick`과 함께 쓸 수 있음) */ onDataCellClick?: DataCellClickCallback; /** CheckBar 행 체크 변경 */ diff --git a/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts b/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts new file mode 100644 index 000000000..8b5887a67 --- /dev/null +++ b/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts @@ -0,0 +1,75 @@ +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 행정동갱신 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +/** 좌측: 감축된 행정구역(범위:동읍면) 자료 */ +export const AdmdstRenewalReducedColumnConfig: ColumnInfoList = { + tabKey: "RENEWAL_REDUCED", + tabLabel: "감축된 행정구역(범위:동읍면) 자료", + rowStartNum: 1, + fixedColumns: 1, + columns: [ + { + name: "adstDstrNm", + fieldName: "adstDstrNm", + header: { text: "행정동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "adstCd", + fieldName: "adstCd", + header: { text: "행정동코드" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: ["adstDstrNm", "adstCd"], +}; + +/** 우측: 추가된 행정구역 자료 */ +export const AdmdstRenewalAddedColumnConfig: ColumnInfoList = { + tabKey: "RENEWAL_ADDED", + tabLabel: "추가된 행정구역 자료", + rowStartNum: 1, + fixedColumns: 1, + columns: [ + { + name: "adstDstrNm", + fieldName: "adstDstrNm", + header: { text: "행정동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "adstCd", + fieldName: "adstCd", + header: { text: "행정동코드" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "lawCd", + fieldName: "lawCd", + header: { text: "법정동코드" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "lawNm", + fieldName: "lawNm", + header: { text: "법정동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: ["adstDstrNm", "adstCd", "lawCd", "lawNm"], +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts b/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts new file mode 100644 index 000000000..460c880e9 --- /dev/null +++ b/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts @@ -0,0 +1,143 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 내국인 인구 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +/** 좌측: 엑셀 업로드 자료 (내국인 전체 리스트) */ +export const DomesticPopulationLeftColumnConfig: ColumnInfoList = { + tabKey: "DOMESTIC_LEFT", + tabLabel: "엑셀 업로드 자료 (내국인 전체 리스트)", + rowStartNum: 1, + fixedColumns: 2, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "코드" }, + width: 120, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "admdstNm", + { + name: "populationGroup", + header: { text: "인구수" }, + items: ["domplWholPpltnCnt", "domplMlPpltnCnt", "domplFmlPpltnCnt"], + }, + ], +}; + +/** 우측: 미매핑 리스트 (작년 대비) */ +export const DomesticPopulationRightColumnConfig: ColumnInfoList = { + tabKey: "DOMESTIC_RIGHT", + tabLabel: "미매핑 리스트 (작년 대비)", + rowStartNum: 1, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "업로드코드" }, + width: 130, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "mpinCd", + fieldName: "mpinCd", + header: { text: "맵핑코드" }, + width: 130, + editable: true + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "admdstNm", + "mpinCd", + { + name: "populationGroup", + header: { text: "인구수" }, + items: ["domplWholPpltnCnt", "domplMlPpltnCnt", "domplFmlPpltnCnt"], + }, + ], +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts b/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts new file mode 100644 index 000000000..ea2723f0e --- /dev/null +++ b/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts @@ -0,0 +1,162 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 외국인 인구 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +/** 좌측: 엑셀 업로드 자료 (외국인 전체 리스트) */ +export const ForeignPopulationLeftColumnConfig: ColumnInfoList = { + tabKey: "FOREIGN_LEFT", + tabLabel: "엑셀 업로드 자료 (외국인 전체 리스트)", + rowStartNum: 1, + fixedColumns: 2, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "코드" }, + width: 120, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "admdstNm", + { + name: "populationGroup", + header: { text: "외국인수" }, + items: ["frgnrWholPpltnCnt", "frgnrMlPpltnCnt", "frgnrFmlPpltnCnt"], + }, + ], +}; + +/** 우측: 미매핑 리스트 (작년 대비) */ +export const ForeignPopulationRightColumnConfig: ColumnInfoList = { + tabKey: "FOREIGN_RIGHT", + tabLabel: "미매핑 리스트 (작년 대비)", + rowStartNum: 1, + fixedColumns: 3, + columns: [ + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "업로드코드" }, + width: 130, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "sido", + fieldName: "sido", + header: { text: "시도" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "gugun", + fieldName: "gugun", + header: { text: "구군" }, + width: 150, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "동명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "mpinCd", + fieldName: "mpinCd", + header: { text: "맵핑코드" }, + width: 130, + editable: true + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "합계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "admdstCd", + "sido", + "gugun", + "admdstNm", + "mpinCd", + { + name: "populationGroup", + header: { text: "외국인수" }, + items: ["frgnrWholPpltnCnt", "frgnrMlPpltnCnt", "frgnrFmlPpltnCnt"], + }, + ], +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/PopulationStatusColumn.ts b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts new file mode 100644 index 000000000..3ea57358e --- /dev/null +++ b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts @@ -0,0 +1,121 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 행정인구현황 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +export const PopulationStatusColumnConfig: ColumnInfoList = { + tabKey: "POPULATION_STATUS", + tabLabel: "행정 인구 현황", + rowStartNum: 1, + fixedColumns: 3, + columns: [ + { + name: "statsYr", + fieldName: "statsYr", + header: { text: "년도" }, + width: 90, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "행정구역코드" }, + width: 140, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "행정구역명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "wholPpltnCnt", + fieldName: "wholPpltnCnt", + header: { text: "총인구" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "내국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "내국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "내국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "외국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "외국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "외국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "statsYr", + "admdstCd", + "admdstNm", + "wholPpltnCnt", + "domplWholPpltnCnt", + "domplMlPpltnCnt", + "domplFmlPpltnCnt", + "frgnrWholPpltnCnt", + "frgnrMlPpltnCnt", + "frgnrFmlPpltnCnt", + ], +}; \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts new file mode 100644 index 000000000..a067b8612 --- /dev/null +++ b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts @@ -0,0 +1,242 @@ +import { ENDPOINT } from "@/shared/config"; +import { stClient } from "@/shared/api/client"; + +import type { + DomesticPopulationListResponse, + DomesticPopulationFinalResponse, + UpperCodesByNameResponse, + DomesticPopulationMappingItem, +} from "../model/ppltn/statistics-domestic-population-interface"; + +// 1. 응답 데이터의 타입을 정의합니다 (이미 정의된 것이 있다면 그것을 사용하세요) +interface ChartDataResponse { + status: string; + data: { + X_AXIS: string[]; + TOT: number[]; + ORIGIN_MALE: (number | null)[]; + ORIGIN_FEMALE: (number | null)[]; + FOREIGN_MALE: (number | null)[]; + FOREIGN_FEMALE: (number | null)[]; + }; + message?: string; +} + +export class StatisticsPpltnApiService { + + /** 내국인 엑셀 임시 업로드 */ + public async uploadDomesticPopulationExcel( + file: File, + statsYr: string, + ): Promise<{ status: string; message?: string }> { + const formData = new FormData(); + formData.append("file", file); + formData.append("statsYr", statsYr); + + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-excel`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return response.data; + } + + /** 내국인 임시 적재 리스트 조회 + * @param opt1 "1" = 전체 리스트(좌측), "2" = 미매핑 리스트(우측) + */ + public async getDomesticPopulationList( + statsYr: string, + opt1: "1" | "2", + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-list`, + { params: { statsYr, opt1 } }, + ); + return response.data; + } + + /** 내국인 최종 마스터 반영 */ + public async saveDomesticPopulationFinal( + statsYr: string, + regList: DomesticPopulationMappingItem[], + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-final`, + { YR: statsYr, regList }, + ); + return response.data; + } + /** 미매핑 행 클릭 시 상위 코드 후보 조회 */ + + public async getUpperCodesByName( + statsYr: string, + admdstNm: string, + admdstCd: string + ): Promise { + // GET 요청으로 변경하고 params 객체를 추가합니다. + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.PPLTN}/upper-codes-by-name`, + { + params: { statsYr, admdstNm,admdstCd } + }, + ); + return response.data; + } + + /* ════════════════════════════════════ + 외국인 + ════════════════════════════════════ */ + + /** 외국인 엑셀 임시 업로드 */ + public async uploadForeignPopulationExcel( + file: File, + statsYr: string, + ): Promise<{ status: string; message?: string }> { + const formData = new FormData(); + formData.append("file", file); + formData.append("statsYr", statsYr); + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/foreign-population-excel`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return response.data; + } + + /** 외국인 임시 적재 리스트 조회 + * @param opt1 "1" = 전체 리스트(좌측), "2" = 미매핑 리스트(우측) + */ + public async getForeignPopulationList( + statsYr: string, + opt1: "1" | "2", + ): Promise<{ status: string; data: object[]; message?: string }> { + const response = await stClient.get<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/foreign-population-list`, + { params: { statsYr, opt1 } }, + ); + return response.data; + } + + /** 외국인 최종 마스터 반영 */ + public async saveForeignPopulationFinal( + statsYr: string, + regList: DomesticPopulationMappingItem[], + ): Promise<{ status: string; message?: string }> { + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/foreign-population-final`, + { statsYr, regList }, + ); + return response.data; + } + + + /* ════════════════════════════════════ + 행정동갱신 + ════════════════════════════════════ */ + + /** 행정동 갱신 — 감축/추가 행정구역 조회 + * @param opt1 "1" = 추가된 행정구역, "2" = 감축된 행정구역 + */ + public async searchChangedArea( + statsYr: string, + opt1: "1" | "2", + ): Promise<{ status: string; data: object[]; message?: string }> { + const response = await stClient.get<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/search`, + { params: { statsYr, opt1 } }, + ); + return response.data; + } + + /** 행정동 갱신 — 감축/추가 반영 */ + public async saveChangedArea( + reducedList: object[], + addedList: object[], + ): Promise<{ status: string; message?: string }> { + const response = await stClient.post<{ status: string; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/save`, + { reducedList, addedList }, + ); + return response.data; + } + + // /** 행정동 갱신 — 법정동코드 후보 조회 (행정동명 클릭 시) */ + // public async getRenewalUpperCodesByName( + // statsYr: string, + // admdstNm: string, + // ): Promise { + // const response = await stClient.get( + // `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/upper-codes-by-name`, + // { + // params: { statsYr, admdstNm } + // }, + // ); + // return response.data; + // } + + /** 행정동 갱신 — 법정동코드 후보 조회 */ +public async getLawCdsByName( + adstCd : string, +): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/law-codes-by-name`, + { + params: { adstCd } + }, + ); + return response.data; +} + /* ════════════════════════════════════ + 행정인구현황 + ════════════════════════════════════ */ + + /** 행정인구현황 — 체크박스 조건에 따른 인구 현황 조회 */ + public async searchPopByEachCondition( + statsYr: string, + oMale: boolean, + oFemale: boolean, + fMale: boolean, + fFemale: boolean, + ): Promise<{ status: string; data: object[]; message?: string }> { + const response = await stClient.get<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/pop-by-condition`, + { + params: { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + } + }, + ); + return response.data; + } + + /** 행정인구현황 시각화 — 기준연도 기준 최대 5년치 차트 데이터 조회 */ + public async getChartDatas1( + statsYr: string, + oMale: boolean, + oFemale: boolean, + fMale: boolean, + fFemale: boolean, + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/chart-datas`, + { + params: { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + } + }, + ); + return response.data; + } +} + + + +export const statisticsPpltnApi = new StatisticsPpltnApiService(); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts b/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts new file mode 100644 index 000000000..0adabf98b --- /dev/null +++ b/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts @@ -0,0 +1,70 @@ +import type { ApiResponse } from "@/entities/statistics/model/statistics-interface"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 내국인 인구 관련 인터페이스 +══════════════════════════════════════════════════ */ + +/** 내국인 인구 임시 적재 행 (좌측 전체 리스트 / 우측 미매핑 리스트 공통) */ +export interface DomesticPopulationItem { + statsYr: string; // 통계연도 + admdstCd: string; // 행정구역코드 + admdstNm: string; // 행정구역명 + domplWholPpltnCnt: number; // 내국인 합계 인구수 + domplMlPpltnCnt: number; // 내국인 남성 인구수 + domplFmlPpltnCnt: number; // 내국인 여성 인구수 + /** 미매핑 리스트 전용 - 매핑 대상 행정구역코드 (상위코드) */ + mpinCd: string; +} + +/** 엑셀 업로드 요청 파라미터 */ +export interface DomesticPopulationUploadParams { + file: File; + statsYr: string; +} + +/** 임시 적재 리스트 조회 요청 파라미터 */ +export interface DomesticPopulationListParams { + statsYr: string; + /** 1: 전체 리스트(좌측), 2: 미매핑 리스트(우측) */ + opt1: '1' | '2'; +} + +/** 수동 매핑 후 최종 반영 요청 파라미터 */ +export interface DomesticPopulationFinalParams { + YR: string; + /** 수동 매핑된 행 목록 (admdstCd → subAdmdstCd 매핑 정보) */ + regList: DomesticPopulationMappingItem[]; +} + +/** 수동 매핑 단건 아이템 */ +export interface DomesticPopulationMappingItem { + statsYr: string; + admdstCd: string; + admdstNm: string; + subAdmdstCd: string; +} + +/** 내국인/외국인 상위 코드 후보 조회 요청 */ +export interface UpperCodesByNameParams { + statsYr: string; + admdstNm: string; +} + +/** 내국인/외국인 상위 코드 후보 단건 아이템 */ +export interface UpperCodeCandidateItem { + admdstCd: string; // 행정동코드 + admdstNm: string; // 행정동명 + [key: string]: string; +} + +/** 행정동 갱신 법정동코드 후보 단건 아이템 */ +export interface LawCodeCandidateItem { + lawCd: string; // 법정동코드 + lawNm: string; // 법정동명 + [key: string]: string; + +} +/* ── API 응답 타입 ── */ +export type DomesticPopulationListResponse = ApiResponse; +export type DomesticPopulationFinalResponse = ApiResponse; +export type UpperCodesByNameResponse = ApiResponse; \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/statistics-interface.ts b/apps/st/src/entities/statistics/model/statistics-interface.ts index 4b001c8a0..d63262e54 100644 --- a/apps/st/src/entities/statistics/model/statistics-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-interface.ts @@ -31,7 +31,7 @@ export interface cancelSubmitResponse { export interface ApiResponse { status: string; - data: T; + data?: T; message?: string; } diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index c7e0afcf6..dfc788394 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -71,6 +71,7 @@ export const ENDPOINT = { INPUT: "/st-input-service/api/v1/input", REVIEW: "/st-management-service/api/v1/review", STATS: "/st-management-service/api/stats", + PPLTN: "/st-management-service/api/v1/ppltn", INPUTCOMMON: "/st-input-service/api/v1/common", INPUTVERIFY: "/st-input-service/api/v1/verify", TOTAL: "/st-management-service/api/v1/stats",