Merge remote-tracking branch 'origin/feature/sein' into iipbs/st/dev
This commit is contained in:
@@ -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();
|
||||
+70
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user