From 82ab1376cc002e0bf3a917db48c61d5bb596a982 Mon Sep 17 00:00:00 2001 From: yangsein Date: Thu, 18 Jun 2026 15:38:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=ED=96=89=EC=A0=95=EB=8F=99=20=ED=83=AD?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/PopulationStatusChart.tsx | 106 +++++++++ .../StatisticsPopulationManagement.tsx | 202 ++++++++++++++++-- .../management/PopulationStatusColumn.ts | 121 +++++++++++ .../api/statistics-ppltn-service.ts | 60 ++++++ 4 files changed, 472 insertions(+), 17 deletions(-) create mode 100644 apps/st/src/components/statistics/management/PopulationStatusChart.tsx create mode 100644 apps/st/src/components/util/column/management/PopulationStatusColumn.ts diff --git a/apps/st/src/components/statistics/management/PopulationStatusChart.tsx b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx new file mode 100644 index 000000000..1b7198135 --- /dev/null +++ b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx @@ -0,0 +1,106 @@ +import { Chart } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + Title, + Tooltip, + Legend, +} from "chart.js"; +import type { ChartOptions } from "chart.js"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend); + +// 차트 설정 외부 정의 (Mixed 타입 명시) +const options: ChartOptions<"bar" | "line"> = { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { stacked: true }, + y: { + stacked: true, + beginAtZero: true, + title: { display: true, text: "인구수(명)" }, + }, + y1: { + position: "right", + beginAtZero: true, + grid: { drawOnChartArea: false }, + title: { display: true, text: "총 인구(명)" }, + }, + }, + plugins: { + legend: { position: "bottom" }, + }, +}; + +export type PopulationChartData = { + X_AXIS: string[]; + TOT: number[]; + ORIGIN_MALE: (number | null)[]; + ORIGIN_FEMALE: (number | null)[]; + FOREIGN_MALE: (number | null)[]; + FOREIGN_FEMALE: (number | null)[]; +}; + +type Props = { + chartData: PopulationChartData | null; +}; + +const SERIES_META = [ + { key: "ORIGIN_MALE", name: "내국인 남성", color: "#3B82F6" }, + { key: "ORIGIN_FEMALE", name: "내국인 여성", color: "#EC4899" }, + { key: "FOREIGN_MALE", name: "외국인 남성", color: "#10B981" }, + { key: "FOREIGN_FEMALE", name: "외국인 여성", color: "#F59E0B" }, +] as const; + +const PopulationStatusChart = ({ chartData }: Props) => { + if (!chartData || chartData.X_AXIS.length === 0) { + return ( +
+ 조회된 데이터가 없습니다. 조회 버튼을 눌러주세요. +
+ ); + } + + const data = { + labels: chartData.X_AXIS.map((yr) => `${yr}년`), + datasets: [ + ...SERIES_META + .filter((s) => chartData[s.key]?.some((v) => v !== null)) + .map((s) => ({ + type: "bar" as const, + label: s.name, + data: chartData[s.key].map((v) => v ?? 0), + backgroundColor: s.color, + stack: "stack1", + })), + { + type: "line" as const, + label: "총 인구", + data: chartData.TOT, + borderColor: "#1F2937", + backgroundColor: "#1F2937", + borderDash: [5, 5], + tension: 0.1, + yAxisID: "y1", + }, + ], + }; + + return ( +
+ {/* 제네릭 타입을 통해 Mixed Chart 지원, 부모 컨테이너 크기에 맞춰 반응형으로 리사이즈됨 */} + + type="bar" + data={data} + options={options} + /> +
+ ); +}; + +export default PopulationStatusChart; diff --git a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx index e1d7ecd94..5c2f0cd0f 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -21,10 +21,12 @@ import { AdmdstRenewalReducedColumnConfig, AdmdstRenewalAddedColumnConfig, } from "@/components/util/column/management/AdmdstRenewalColumn"; +import { PopulationStatusColumnConfig } from "@/components/util/column/management/PopulationStatusColumn"; import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; import { UpperCodeSelectDialog } from "./UpperCodeSelectDialog"; +import PopulationStatusChart, { type PopulationChartData } from "./PopulationStatusChart"; const TAB_LIST = [ { key: "domestic", label: "행정인구등록" }, @@ -59,6 +61,10 @@ const renewalAddedColumnInfo: ColumnInfo = { columns: AdmdstRenewalAddedColumnConfig.columns ?? [], columnLayout: AdmdstRenewalAddedColumnConfig.columnLayout ?? [], }; +const populationStatusColumnInfo: ColumnInfo = { + columns: PopulationStatusColumnConfig.columns ?? [], + columnLayout: PopulationStatusColumnConfig.columnLayout ?? [], +}; /* ── 엑셀 헤더 검증 ── */ const validateExcelHeader = (file: File, requiredKeywords: string[]): Promise => { @@ -107,6 +113,21 @@ const StatisticsPopulationManagement = () => { const [renewalReducedRowData, setRenewalReducedRowData] = useState[]>([]); const [renewalAddedRowData, setRenewalAddedRowData] = useState[]>([]); + /* ── 행정인구현황 데이터 ── */ + const [statusRowData, setStatusRowData] = useState[]>([]); + const [statusOMale, setStatusOMale] = useState(true); + const [statusOFemale, setStatusOFemale] = useState(true); + const [statusFMale, setStatusFMale] = useState(true); + const [statusFFemale, setStatusFFemale] = useState(true); + + /* ── 행정인구현황 시각화 데이터 ── */ + const [vizYear, setVizYear] = useState(String(new Date().getFullYear() - 1)); + const [vizOMale, setVizOMale] = useState(true); + const [vizOFemale, setVizOFemale] = useState(true); + const [vizFMale, setVizFMale] = useState(true); + const [vizFFemale, setVizFFemale] = useState(true); + const [chartData, setChartData] = useState(null); + /* ── 상위 코드 다이얼로그 ── */ const [dialogOpen, setDialogOpen] = useState(false); const [candidates, setCandidates] = useState([]); @@ -340,6 +361,38 @@ const StatisticsPopulationManagement = () => { } }; + /* ════════════════ 행정인구현황 시각화 ════════════════ */ + const handleVizSearch = async (yr: string) => { + if (!yr) { alert("연도를 선택해주세요."); return; } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.getChartDatas1(yr, vizOMale, vizOFemale, vizFMale, vizFFemale); + if (res.status === "success") { + setChartData(res.data as PopulationChartData); + } else { + alert(res.message || "조회 중 오류가 발생했습니다."); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleVizSelectAll = () => { + setVizOMale(true); + setVizOFemale(true); + setVizFMale(true); + setVizFFemale(true); + }; + + const handleVizDeselectAll = () => { + setVizOMale(false); + setVizOFemale(false); + setVizFMale(false); + setVizFFemale(false); + }; + /* ════════════════ 공통 ════════════════ */ const handleSearch = async (values: SearchFormValues) => { lastSearchedValuesRef.current = values; @@ -375,6 +428,32 @@ const StatisticsPopulationManagement = () => { } } else if (activeTab === "renewal") { await loadRenewalData(values); + } else if (activeTab === "status") { + const yr = values.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); return; } + if (!statusOMale && !statusOFemale && !statusFMale && !statusFFemale) { + alert("체크 박스를 모두 해제하신 상태에서는\n조회가 불가능합니다.\n(최소 1개 이상의 체크 박스 선택 후\n조회를 진행해주세요.)"); + return; + } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.searchPopByEachCondition( + yr, statusOMale, statusOFemale, statusFMale, statusFFemale, + ); + if (res.status === "success") { + setStatusRowData(toGridRows((res.data as object[]) ?? [])); + } else { + alert(res.message || "조회 중 오류가 발생했습니다."); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } else if (activeTab === "statusViz") { + const yr = values.year ?? ""; + setVizYear(yr); + await handleVizSearch(yr); } }; @@ -385,6 +464,17 @@ const StatisticsPopulationManagement = () => { setForeignRightRowData([]); setRenewalReducedRowData([]); setRenewalAddedRowData([]); + setStatusRowData([]); + setStatusOMale(true); + setStatusOFemale(true); + setStatusFMale(true); + setStatusFFemale(true); + setVizYear(String(new Date().getFullYear() - 1)); + setVizOMale(true); + setVizOFemale(true); + setVizFMale(true); + setVizFFemale(true); + setChartData(null); setActiveTab("domestic"); lastSearchedValuesRef.current = null; }; @@ -483,7 +573,57 @@ const StatisticsPopulationManagement = () => { - +
+ + {activeTab === "status" && ( +
+ + + + +
+ )} + {activeTab === "statusViz" && ( +
+ + + + + + +
+ )} +
{isLoading && (
@@ -510,7 +650,7 @@ const StatisticsPopulationManagement = () => { 엑셀 업로드 자료 (내국인 전체 리스트)
-
+
-
+
+ + 엑셀 업로드 자료 (외국인 전체 리스트) + +
+ + {/* pr-8 (오른쪽 패딩)을 추가하여 버튼을 왼쪽으로 밀어냅니다 */} +
+ +
{
-
+
감축된 행정구역(범위:동읍면) 자료
-
+
{
-
행정인구현황 구현 예정
+ {/* 그리드 라벨 */} +
+ + 행정 인구 현황 + +
+ +
+ + {/* ── 행정인구현황 시각화 탭 ── */} -
행정인구현황 시각화 구현 예정
+ + 행정인구현황 시각화 (기준연도 ~ 과거 자료(최대 5년) / *단위: 명) + + +
+ +
diff --git a/apps/st/src/components/util/column/management/PopulationStatusColumn.ts b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts new file mode 100644 index 000000000..3ea57358e --- /dev/null +++ b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts @@ -0,0 +1,121 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 행정인구현황 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +export const PopulationStatusColumnConfig: ColumnInfoList = { + tabKey: "POPULATION_STATUS", + tabLabel: "행정 인구 현황", + rowStartNum: 1, + fixedColumns: 3, + columns: [ + { + name: "statsYr", + fieldName: "statsYr", + header: { text: "년도" }, + width: 90, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "행정구역코드" }, + width: 140, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "행정구역명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "wholPpltnCnt", + fieldName: "wholPpltnCnt", + header: { text: "총인구" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "내국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "내국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "내국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "외국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "외국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "외국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "statsYr", + "admdstCd", + "admdstNm", + "wholPpltnCnt", + "domplWholPpltnCnt", + "domplMlPpltnCnt", + "domplFmlPpltnCnt", + "frgnrWholPpltnCnt", + "frgnrMlPpltnCnt", + "frgnrFmlPpltnCnt", + ], +}; \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts index b4eb5a0aa..c4fbec777 100644 --- a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts @@ -8,6 +8,20 @@ import type { 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 { /** 내국인 엑셀 임시 업로드 */ @@ -154,6 +168,52 @@ export class StatisticsPpltnApiService { ); 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.post<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/pop-by-condition`, + { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + }, + ); + return response.data; + } + +/** 행정인구현황 시각화 — 기준연도 기준 최대 5년치 차트 데이터 조회 */ + public async getChartDatas1( + statsYr: string, + oMale: boolean, + oFemale: boolean, + fMale: boolean, + fFemale: boolean, + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/chart-datas`, + { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + }, + ); + return response.data; + } }