Merge remote-tracking branch 'origin/feature/sein' into iipbs/st/dev

This commit is contained in:
2026-06-30 14:40:51 +09:00
25 changed files with 2486 additions and 5 deletions
@@ -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<string, string | number>,
fieldName,
itemIndex: clickData.itemIndex
});
};
return () => {
(gridView as any).onCellDblClicked = undefined;
};
}, [onCellDblClick]);
useLayoutEffect(() => {
const gv = gridRef.current?.gridView;
if (!gv || !cellStyleCallback) return;
@@ -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<typeof useAdmdstRenewalTab>;
statsYr: string;
};
const AdmdstRenewalTab = ({ tab, statsYr }: Props) => {
return (
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center mb-[1.1rem]">
<span className="text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500">
감축된 행정구역(범위:동읍면) 자료
</span>
</div>
<div className="h-[3.4rem]" />
<DataGrid
columnInfo={reducedColumnInfo}
rowStartNum={AdmdstRenewalReducedColumnConfig.rowStartNum}
excelExportFileSuffix="감축된행정구역"
rowData={asGridRowData(tab.reducedRowData)}
setRowData={asGridRowSetter(tab.setReducedRowData)}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={AdmdstRenewalReducedColumnConfig.fixedColumns}
enableSort={false}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center mb-[0.4rem]">
<span className="text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500">
추가된 행정구역 자료
</span>
</div>
<div className="flex justify-end pr-[1rem] mb-[0.4rem]">
<Button
type="button"
color="primary"
disabled={tab.isLoading || (tab.reducedRowData.length === 0 && tab.addedRowData.length === 0)}
onClick={() => void tab.handleFinal(statsYr)}
className="mt-[0.5rem] px-[4rem] py-[0.8rem] text-[1.4rem]"
>
반영
</Button>
</div>
<DataGrid
columnInfo={addedColumnInfo}
rowStartNum={AdmdstRenewalAddedColumnConfig.rowStartNum}
excelExportFileSuffix="추가된행정구역"
rowData={asGridRowData(tab.addedRowData)}
setRowData={asGridRowSetter(tab.setAddedRowData)}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={AdmdstRenewalAddedColumnConfig.fixedColumns}
enableSort={false}
onCellDblClick={({ row, fieldName }) => {
if (fieldName !== "lawCd") return; // lawCd 셀만!
void tab.handleRowClick(fromGridRow<AdmdstRenewalAddedItem>(row), statsYr);
}}
/>
</div>
<UpperCodeSelectDialog
open={tab.dialogOpen}
onOpenChange={tab.setDialogOpen}
candidates={tab.candidates}
codeField="lawCd"
nameField="lawNm"
extraFields={[
{ field: "rgnCd", header: "지역코드" },
{ field: "lv", header: "레벨" },
]}
onSelect={tab.handleCodeSelect}
/>
</div>
);
};
export default AdmdstRenewalTab;
@@ -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<typeof usePopulationMappingTab<DomesticPopulationItem>>;
type Props = {
tab: DomesticTab;
statsYr: string;
onFileButtonClick: () => void;
};
const DomesticPopulationTab = ({ tab, statsYr, onFileButtonClick }: Props) => {
const rightGridRef = useRef<RealGridReact | null>(null);
return (
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center mb-[0.4rem]">
<span className="text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500">
엑셀 업로드 자료 (내국인 전체 리스트)
</span>
</div>
<div className="flex justify-end pr-2 mb-[0.4rem]">
<Button
type="button"
color="red"
onClick={onFileButtonClick}
className="mt-[0.5rem] px-[3rem] py-[0.8rem] text-[1.4rem]"
>
엑셀 업로드
</Button>
</div>
<DataGrid
columnInfo={leftColumnInfo}
rowStartNum={DomesticPopulationLeftColumnConfig.rowStartNum}
excelExportFileSuffix="내국인전체리스트"
rowData={asGridRowData(tab.leftRowData)}
setRowData={asGridRowSetter(tab.setLeftRowData)}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={DomesticPopulationLeftColumnConfig.fixedColumns}
enableSort={false}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center mb-[0.4rem]">
<span className="text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500">
미매핑 리스트 (작년 대비)
</span>
</div>
<div className="flex justify-end pr-[1rem] mb-[0.4rem]">
<Button
type="button"
color="primary"
disabled={tab.isLoading || tab.rightRowData.length === 0}
onClick={() => void tab.handleFinal(statsYr)}
className="mt-[0.5rem] px-[4rem] py-[0.8rem] text-[1.4rem]"
>
반영
</Button>
</div>
<DataGrid
columnInfo={rightColumnInfo}
rowStartNum={DomesticPopulationRightColumnConfig.rowStartNum}
excelExportFileSuffix="미매핑리스트"
rowData={asGridRowData(tab.rightRowData)}
setRowData={asGridRowSetter(tab.setRightRowData)}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={DomesticPopulationRightColumnConfig.fixedColumns}
enableSort={false}
getGridRef={rightGridRef} // ← 추가
onDataCellClick={({ row, fieldName }) => {
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<DomesticPopulationItem>(row), statsYr);
}}
/>
</div>
<UpperCodeSelectDialog
open={tab.dialogOpen}
onOpenChange={tab.setDialogOpen}
candidates={tab.candidates}
codeField="admdstCd"
nameField="admdstNm"
onSelect={tab.handleCodeSelect}
/>
</div>
);
};
export default DomesticPopulationTab;
@@ -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<typeof usePopulationMappingTab<ForeignPopulationItem>>;
type Props = {
tab: ForeignTab;
statsYr: string;
onFileButtonClick: () => void;
};
const ForeignPopulationTab = ({ tab, statsYr, onFileButtonClick }: Props) => {
const rightGridRef = useRef<RealGridReact | null>(null);
return (
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center mb-[0.4rem]">
<span className="text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500">
엑셀 업로드 자료 (외국인 전체 리스트)
</span>
</div>
<div className="flex justify-end pr-2 mb-[0.4rem]">
<Button
type="button"
color="red"
onClick={onFileButtonClick}
className="mt-[0.5rem] px-[3rem] py-[0.8rem] text-[1.4rem]"
>
엑셀 업로드
</Button>
</div>
<DataGrid
columnInfo={leftColumnInfo}
rowStartNum={ForeignPopulationLeftColumnConfig.rowStartNum}
excelExportFileSuffix="외국인전체리스트"
rowData={asGridRowData(tab.leftRowData)}
setRowData={asGridRowSetter(tab.setLeftRowData)}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={ForeignPopulationLeftColumnConfig.fixedColumns}
enableSort={false}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center mb-[0.4rem]">
<span className="text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500">
미매핑 리스트 (작년 대비)
</span>
</div>
<div className="flex justify-end pr-[1rem] mb-[0.4rem]">
<Button
type="button"
color="primary"
disabled={tab.isLoading || tab.rightRowData.length === 0}
onClick={() => void tab.handleFinal(statsYr)}
className="mt-[0.5rem] px-[4rem] py-[0.8rem] text-[1.4rem]"
>
반영
</Button>
</div>
<DataGrid
columnInfo={rightColumnInfo}
rowStartNum={ForeignPopulationRightColumnConfig.rowStartNum}
excelExportFileSuffix="외국인미매핑리스트"
rowData={asGridRowData(tab.rightRowData)}
setRowData={asGridRowSetter(tab.setRightRowData)}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={ForeignPopulationRightColumnConfig.fixedColumns}
enableSort={false}
getGridRef={rightGridRef}
onDataCellClick={({ row, fieldName }) => {
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<ForeignPopulationItem>(row), statsYr);
}}
/>
</div>
<UpperCodeSelectDialog
open={tab.dialogOpen}
onOpenChange={tab.setDialogOpen}
candidates={tab.candidates}
codeField="admdstCd"
nameField="admdstNm"
onSelect={tab.handleCodeSelect}
/>
</div>
);
};
export default ForeignPopulationTab;
@@ -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 (
<div className="flex items-center justify-center h-full min-h-[40rem] w-full text-gray-400 text-[1.4rem] border border-gray-200 rounded">
조회된 데이터가 없습니다. 조회 버튼을 눌러주세요.
</div>
);
}
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 (
<div className="h-full w-full border border-gray-200 rounded p-4">
{/* 제네릭 타입을 통해 Mixed Chart 지원, 부모 컨테이너 크기에 맞춰 반응형으로 리사이즈됨 */}
<Chart<"bar" | "line", (string | number)[], string>
type="bar"
data={data}
options={options}
/>
</div>
);
};
export default PopulationStatusChart;
@@ -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<typeof usePopulationStatusTab>;
};
const PopulationStatusTab = ({ tab }: Props) => {
return (
<>
<div className="flex items-center mb-[1rem]">
<span className="text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500">
행정 인구 현황
</span>
</div>
<DataGrid
columnInfo={columnInfo}
rowStartNum={PopulationStatusColumnConfig.rowStartNum}
excelExportFileSuffix="행정인구현황"
rowData={asGridRowData(tab.rowData)}
setRowData={asGridRowSetter(tab.setRowData)}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={PopulationStatusColumnConfig.fixedColumns}
enableSort={false}
/>
</>
);
};
export default PopulationStatusTab;
@@ -0,0 +1,22 @@
import PopulationStatusChart from "./PopulationStatusChart";
import type { usePopulationVizTab } from "@/components/statistics/management/hooks/usePopulationVizTab";
type Props = {
tab: ReturnType<typeof usePopulationVizTab>;
};
const PopulationVizTab = ({ tab }: Props) => {
return (
<>
<span className="inline-block text-[1.8rem] font-semibold text-gray-700 before:content-['●'] before:mr-1 before:text-blue-500 mb-[1.5rem]">
행정인구현황 시각화 (기준연도 ~ 과거 자료(최대 5년) / *단위: 명)
</span>
<div className="w-full h-[77rem]">
<PopulationStatusChart chartData={tab.chartData} />
</div>
</>
);
};
export default PopulationVizTab;
@@ -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<SearchFormValues | null>(null);
/* ── 탭별 상태/로직은 전부 훅에서 가져온다. 탭이 화면에 보이지 않아도 훅은 항상 호출되므로
탭을 왔다갔다 해도 데이터가 유지된다 (원본 컴포넌트와 동일한 동작) ── */
const domesticTab = usePopulationMappingTab<DomesticPopulationItem>({
kind: "domestic",
getList: statisticsPpltnApi.getDomesticPopulationList,
uploadExcel: statisticsPpltnApi.uploadDomesticPopulationExcel,
saveFinal: statisticsPpltnApi.saveDomesticPopulationFinal,
getUpperCodesByName: statisticsPpltnApi.getUpperCodesByName,
requiredHeaderKeywords: ["행정기관코드", "행정기관"],
invalidFileMessage: "행정인구(내국인) 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요.",
});
const foreignTab = usePopulationMappingTab<ForeignPopulationItem>({
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 (
<>
<SearchForm sido={false} />
</>
<div className="relative">
<input
ref={domesticTab.fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={(e) => void domesticTab.handleFileChange(e, statsYr)}
/>
<input
ref={foreignTab.fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={(e) => void foreignTab.handleFileChange(e, statsYr)}
/>
<div className="relative">
<SearchForm sido={false} gugun={false} onSubmit={handleSearch} onReset={handleReset} />
{activeTab === "status" && (
<div className="absolute top-0 left-[16rem] h-full flex items-center gap-6 px-4">
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeOriginMale}
onChange={(e) => statusTab.setIncludeOriginMale(e.target.checked)}
className="size-4"
/>
내국인 남성 포함
</label>
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeOriginFemale}
onChange={(e) => statusTab.setIncludeOriginFemale(e.target.checked)}
className="size-4"
/>
내국인 여성 포함
</label>
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeForeignMale}
onChange={(e) => statusTab.setIncludeForeignMale(e.target.checked)}
className="size-4"
/>
외국인 남성 포함
</label>
<label className="text-[1.3rem] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-label cursor-pointer flex items-center gap-1.5">
<input
type="checkbox"
checked={statusTab.includeForeignFemale}
onChange={(e) => statusTab.setIncludeForeignFemale(e.target.checked)}
className="size-4"
/>
외국인 여성 포함
</label>
</div>
)}
{activeTab === "statusViz" && (
<div className="absolute top-0 left-[16rem] h-full flex items-center gap-5 px-4 flex-nowrap">
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeOriginMale}
onChange={(e) => vizTab.setIncludeOriginMale(e.target.checked)}
className="size-4"
/>
내국인 남성
</label>
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeOriginFemale}
onChange={(e) => vizTab.setIncludeOriginFemale(e.target.checked)}
className="size-4"
/>
내국인 여성
</label>
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeForeignMale}
onChange={(e) => vizTab.setIncludeForeignMale(e.target.checked)}
className="size-4"
/>
외국인 남성
</label>
<label className="text-[1.3rem] font-medium leading-none text-label cursor-pointer flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<input
type="checkbox"
checked={vizTab.includeForeignFemale}
onChange={(e) => vizTab.setIncludeForeignFemale(e.target.checked)}
className="size-4"
/>
외국인 여성
</label>
<Button
type="button"
color="primary"
onClick={vizTab.selectAll}
className="px-[1rem] py-[0.5rem] text-[1.2rem] shrink-0 whitespace-nowrap"
>
전체선택
</Button>
<Button
type="button"
color="primary"
onClick={vizTab.deselectAll}
className="px-[1rem] py-[0.5rem] text-[1.2rem] shrink-0 whitespace-nowrap"
>
전체해제(총 인구만 조회)
</Button>
</div>
)}
</div>
{isLoading && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<Tabs
value={activeTab}
onValueChange={(v) => void handleActiveTabChange(v)}
className="w-full"
variant="default"
>
<TabsList>
{TAB_LIST.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="domestic">
<DomesticPopulationTab
tab={domesticTab}
statsYr={statsYr}
onFileButtonClick={() => domesticTab.fileInputRef.current?.click()}
/>
</TabsContent>
<TabsContent value="foreign">
<ForeignPopulationTab
tab={foreignTab}
statsYr={statsYr}
onFileButtonClick={() => foreignTab.fileInputRef.current?.click()}
/>
</TabsContent>
<TabsContent value="renewal">
<AdmdstRenewalTab tab={renewalTab} statsYr={statsYr} />
</TabsContent>
<TabsContent value="status">
<PopulationStatusTab tab={statusTab} />
</TabsContent>
<TabsContent value="statusViz">
<PopulationVizTab tab={vizTab} />
</TabsContent>
</Tabs>
</div>
</div>
);
};
export default StatisticsPopulationManagement;
export default StatisticsPopulationManagement;
@@ -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<string, string>[];
codeField: string;
nameField: string;
onSelect: (item: Record<string, string>) => void;
extraFields?: { field: string; header: string }[];
}
export const UpperCodeSelectDialog = ({
open,
onOpenChange,
candidates,
codeField,
nameField,
extraFields,
onSelect,
}: UpperCodeSelectDialogProps) => {
const [selectedCode, setSelectedCode] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-[50rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>상위 코드 리스트</DialogTitle>
</DialogHeader>
<div className="border border-gray-300 rounded overflow-hidden">
<table className="w-full text-[1.3rem]">
<thead className="sticky top-0 z-10">
<tr className="bg-blue-600 text-white">
<th className="py-[0.8rem] px-[1rem] text-center font-semibold border-r border-blue-500 w-[40%]">
코드
</th>
<th className="py-[0.8rem] px-[1rem] text-center font-semibold">
동명
</th>
{extraFields?.map((f) => (
<th key={f.field} className="py-[0.8rem] px-[1rem] text-center font-semibold border-l border-blue-500">
{f.header}
</th>
))}
</tr>
</thead>
</table>
<div className="max-h-[40rem] overflow-y-auto">
<table className="w-full text-[1.3rem]">
<tbody>
{candidates.length === 0 ? (
<tr>
<td colSpan={2} className="py-[1rem] text-center text-gray-400">
후보 코드가 없습니다.
</td>
</tr>
) : (
candidates.map((item,index) => (
<tr
key={`${item[codeField]}-${index}`} // ← index 추가
onClick={() => setSelectedCode(item[codeField] ?? "")}
className={`cursor-pointer border-b border-gray-200 transition-colors
${selectedCode === item[codeField]
? "bg-blue-100"
: "hover:bg-gray-50"
}`}
>
<td className="py-[0.7rem] px-[1rem] text-center border-r border-gray-200 w-[40%]">
{item[codeField]}
</td>
<td className="py-[0.7rem] px-[1rem] text-center">
{item[nameField]}
</td>
{extraFields?.map((f) => (
<td key={f.field} className="py-[0.7rem] px-[1rem] text-center border-l border-gray-200">
{item[f.field]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<DialogFooter className="flex flex-col gap-2">
<Button
type="button"
color="primary"
disabled={!selectedCode}
onClick={handleSelect}
className="w-full py-[1rem] text-[1.4rem]"
>
선택
</Button>
<Button
type="button"
color="gray"
onClick={handleClose}
className="w-full py-[1rem] text-[1.4rem]"
>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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<AdmdstRenewalReducedItem[]>([]);
const [addedRowData, setAddedRowData] = useState<AdmdstRenewalAddedItem[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [candidates, setCandidates] = useState<UpperCodeCandidateItem[]>([]);
const selectedRowIndexRef = useRef<number | null>(null);
/** OPT_1: "1" = 추가된 행정구역, "2" = 감축된 행정구역 (원본 그대로) */
const loadAll = async (statsYr: string): Promise<boolean> => {
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<string, string>) => {
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,
};
}
@@ -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 <T>(action: () => Promise<T>, options: AsyncActionOptions = {}): Promise<T | undefined> => {
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 };
}
@@ -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<boolean> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
const workbook = XLSX.read(data, { type: "binary" });
const sheetName = workbook.SheetNames[0];
if (!sheetName) { resolve(false); return; }
const sheet = workbook.Sheets[sheetName];
if (!sheet) { resolve(false); return; }
const rows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 });
const headerRow = (rows[0] as string[]).map((h) => String(h ?? ""));
const isValid = requiredKeywords.every((kw) => headerRow.some((h) => h.includes(kw)));
resolve(isValid);
} catch {
resolve(false);
}
};
reader.readAsBinaryString(file);
});
};
/**
* 내국인/외국인 탭이 공유하는 "좌측 전체 리스트 + 우측 미매핑 리스트" 패턴 훅.
* 두 탭은 API 함수와 필드명만 다를 뿐 동작이 완전히 동일해서 config로 주입받는다.
*/
export function usePopulationMappingTab<TRow extends PopulationMappingRow>(
config: PopulationMappingApiConfig<TRow>,
) {
const { setMessage: alert } = useAlertStore((state) => state);
const { isLoading, run } = useAsyncAction(alert);
const [leftRowData, setLeftRowData] = useState<TRow[]>([]);
const [rightRowData, setRightRowData] = useState<TRow[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [candidates, setCandidates] = useState<UpperCodeCandidateItem[]>([]);
const selectedRowIndexRef = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadLeft = async (statsYr: string) => {
const res = await config.getList(statsYr, "1");
if (res.status === "success") setLeftRowData(toGridRows((res.data as object[]) ?? []) as unknown as TRow[]);
};
const loadRight = async (statsYr: string) => {
const res = await config.getList(statsYr, "2");
if (res.status === "success") setRightRowData(toGridRows((res.data as object[]) ?? []) as unknown as TRow[]);
};
/** 탭 최초 진입/활성화 시 - 좌/우 모두 갱신 (업로드 직후에도 사용) */
const loadAll = async (statsYr: string): Promise<boolean> => {
if (!statsYr) { alert("연도를 선택해주세요."); return false; }
const result = await run(async () => {
await loadLeft(statsYr);
await loadRight(statsYr);
return true;
});
return result ?? false;
};
/**
* 검색 버튼 클릭 시 - 좌측은 항상 갱신하고, 우측은 비어있을 때만 갱신한다.
* (사용자가 우측에서 수동으로 매핑한 값을 재검색 시 날려버리지 않기 위함 - 원본 동작 그대로 유지)
*/
const search = async (statsYr: string): Promise<boolean> => {
if (!statsYr) { alert("연도를 선택해주세요."); return false; }
const result = await run(async () => {
await loadLeft(statsYr);
if (rightRowData.length === 0) await loadRight(statsYr);
return true;
});
return result ?? false;
};
/** 탭 전환 시 - 아직 한 번도 로드되지 않았을 때만 로드 */
const loadOnActivate = async (statsYr: string) => {
if (leftRowData.length === 0) await loadAll(statsYr);
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>, statsYr: string) => {
const file = e.target.files?.[0];
if (!file) return;
const isValidExt = [".xlsx", ".xls", ".csv"].some((ext) => file.name.toLowerCase().endsWith(ext));
if (!isValidExt) { alert("xlsx, xls, csv 파일만 업로드 가능합니다."); e.target.value = ""; return; }
const isValidHeader = await validateExcelHeader(file, config.requiredHeaderKeywords);
if (!isValidHeader) { alert(config.invalidFileMessage); e.target.value = ""; return; }
if (!statsYr) { alert("연도를 선택해주세요."); e.target.value = ""; return; }
await run(async () => {
const res = await config.uploadExcel(file, statsYr);
if (res.status === "success") {
await loadAll(statsYr);
alert("업로드가 완료되었습니다.");
} else {
alert(res.message || "업로드 중 오류가 발생했습니다.");
}
});
e.target.value = "";
};
const handleFinal = async (statsYr: string) => {
if (!statsYr) { alert("먼저 조회를 진행해주세요."); return; }
if (rightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; }
await run(async () => {
const regList = rightRowData
.filter((row) => String(row.mpinCd ?? "").trim() !== "")
.map((row) => ({
statsYr,
admdstCd: String(row.admdstCd ?? ""),
admdstNm: String(row.admdstNm ?? ""),
subAdmdstCd: String(row.mpinCd ?? ""),
}));
const res = await config.saveFinal(statsYr, regList);
if (res.status === "success") {
await loadLeft(statsYr);
alert("반영이 완료되었습니다.");
} else {
alert(res.message || "반영 중 오류가 발생했습니다.");
}
});
};
const handleRightRowClick = async (rowData: TRow, statsYr: string) => {
const admdstCd = String(rowData.admdstCd ?? "");
if (!admdstCd) { alert("행정구역코드가 없습니다."); return; }
await run(
async () => {
const res = await config.getUpperCodesByName(statsYr, "", admdstCd); // ← admdstCd 추가
if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) {
setCandidates(res.data);
const rowIndex = rightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd);
selectedRowIndexRef.current = rowIndex;
setDialogOpen(true);
} else {
alert("해당 지역명에 대한 상위 코드 후보가 없습니다.");
}
},
{ errorMessage: "상위 코드 조회 중 오류가 발생했습니다." },
);
};
const handleCodeSelect = (item: Record<string, string>) => {
const rowIndex = selectedRowIndexRef.current;
if (rowIndex === null) return;
setRightRowData((prev) =>
prev.map((row, idx) => (idx === rowIndex ? { ...row, mpinCd: item["admdstCd"] ?? "" } : row)),
);
selectedRowIndexRef.current = null;
};
const reset = () => {
setLeftRowData([]);
setRightRowData([]);
setDialogOpen(false);
setCandidates([]);
selectedRowIndexRef.current = null;
};
return {
isLoading,
leftRowData,
rightRowData,
setLeftRowData,
setRightRowData,
fileInputRef,
dialogOpen,
setDialogOpen,
candidates,
loadAll,
search,
loadOnActivate,
handleFileChange,
handleFinal,
handleRightRowClick,
handleCodeSelect,
reset,
};
}
@@ -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<PopulationStatusItem[]>([]);
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<boolean> => {
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,
};
}
@@ -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<PopulationChartData | null>(null);
const search = async (statsYr: string): Promise<boolean> => {
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,
};
}
@@ -0,0 +1,27 @@
import type { Dispatch, SetStateAction } from "react";
export type GridRow = Record<string, string | number>;
/**
* DataGrid가 제네릭이 아니라 rowData: Record<string, string|number>[] 를 그대로 요구함
* (실제 타입 에러로 확인됨: ts(2322), "missing properties from PopulationStatusItem").
*
* 훅/탭 내부에서는 명시적 도메인 타입(PopulationStatusItem 등)을 그대로 쓰고,
* DataGrid에 넘기는 이 경계에서만 캐스팅한다. 내부 로직의 타입 안정성은 유지하면서
* 외부 라이브러리(DataGrid)의 느슨한 타입과의 경계만 명시적으로 표시하는 용도.
*/
export function asGridRowData<T>(data: T[]): GridRow[] {
return data as unknown as GridRow[];
}
export function asGridRowSetter<T>(setter: Dispatch<SetStateAction<T[]>>): Dispatch<SetStateAction<GridRow[]>> {
return setter as unknown as Dispatch<SetStateAction<GridRow[]>>;
}
/**
* DataGrid의 onRowClick 등에서 넘어오는 GridRow(Record<string,string|number>)를
* 도메인 타입으로 되돌릴 때 사용. `as unknown as T`를 매번 직접 쓰지 않도록 모아둠.
*/
export function fromGridRow<T>(row: GridRow): T {
return row as unknown as T;
}
@@ -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<ForeignPopulationItem[]>;
/* ══════════════════════════════════════════════════
행정동갱신 관련 타입
══════════════════════════════════════════════════ */
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<PopulationStatusItem[]>;
/* ══════════════════════════════════════════════════
내국인/외국인 공용 매핑 탭(좌:전체 리스트, 우:미매핑 리스트) 공통 타입
══════════════════════════════════════════════════ */
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<TRow extends PopulationMappingRow> {
kind: MappingTabKind;
/** opt1: "1" = 전체 리스트(좌측), "2" = 미매핑 리스트(우측) */
getList: (statsYr: string, opt1: "1" | "2") => Promise<ApiResponse<TRow[]>>;
uploadExcel: (file: File, statsYr: string) => Promise<ApiResponse<unknown>>;
saveFinal: (statsYr: string, regList: PopulationMappingRegItem[]) => Promise<ApiResponse<unknown>>;
getUpperCodesByName: (statsYr: string, admdstNm: string, admdstCd: string) => Promise<ApiResponse<UpperCodeCandidateItem[]>>;
/** 업로드된 엑셀 헤더에 반드시 포함되어야 하는 키워드 목록 */
requiredHeaderKeywords: string[];
/** 헤더 검증 실패 시 노출할 안내 메시지 */
invalidFileMessage: string;
}
export type { UpperCodeCandidateItem };
@@ -395,6 +395,8 @@ export type DataGridProps = {
/** 외부에서 gridRef에 직접 접근하기 위한 ref (setRows/addRows/setValue 직접 호출용) */
getGridRef?: React.MutableRefObject<RealGridReact | null>;
onRowClick?: (rowData: Record<string, string | number>) => void; // 행 클릭 이벤트
onRowDblClick?: (rowData: Record<string, string | number>) => void; // ← 추가
onCellDblClick?: DataCellClickCallback;
/** 데이터 셀 클릭 시 행 + 클릭한 필드명 전달 (`onRowClick`과 함께 쓸 수 있음) */
onDataCellClick?: DataCellClickCallback;
/** CheckBar 행 체크 변경 */
@@ -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"],
};
@@ -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"],
},
],
};
@@ -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"],
},
],
};
@@ -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",
],
};
@@ -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<DomesticPopulationListResponse> {
const response = await stClient.get<DomesticPopulationListResponse>(
`${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-list`,
{ params: { statsYr, opt1 } },
);
return response.data;
}
/** 내국인 최종 마스터 반영 */
public async saveDomesticPopulationFinal(
statsYr: string,
regList: DomesticPopulationMappingItem[],
): Promise<DomesticPopulationFinalResponse> {
const response = await stClient.post<DomesticPopulationFinalResponse>(
`${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-final`,
{ YR: statsYr, regList },
);
return response.data;
}
/** 미매핑 행 클릭 시 상위 코드 후보 조회 */
public async getUpperCodesByName(
statsYr: string,
admdstNm: string,
admdstCd: string
): Promise<UpperCodesByNameResponse> {
// GET 요청으로 변경하고 params 객체를 추가합니다.
const response = await stClient.get<UpperCodesByNameResponse>(
`${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<UpperCodesByNameResponse> {
// const response = await stClient.get<UpperCodesByNameResponse>(
// `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/upper-codes-by-name`,
// {
// params: { statsYr, admdstNm }
// },
// );
// return response.data;
// }
/** 행정동 갱신 — 법정동코드 후보 조회 */
public async getLawCdsByName(
adstCd : string,
): Promise<UpperCodesByNameResponse> {
const response = await stClient.get<UpperCodesByNameResponse>(
`${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<ChartDataResponse> {
const response = await stClient.get<ChartDataResponse>(
`${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();
@@ -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<DomesticPopulationItem[]>;
export type DomesticPopulationFinalResponse = ApiResponse<string>;
export type UpperCodesByNameResponse = ApiResponse<UpperCodeCandidateItem[]>;
@@ -31,7 +31,7 @@ export interface cancelSubmitResponse {
export interface ApiResponse<T> {
status: string;
data: T;
data?: T;
message?: string;
}
+1
View File
@@ -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",