feat:행정동 탭코드 추가

This commit is contained in:
yangsein
2026-06-18 15:38:10 +09:00
parent 5ea68435ed
commit 82ab1376cc
4 changed files with 472 additions and 17 deletions
@@ -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;
@@ -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<boolean> => {
@@ -107,6 +113,21 @@ const StatisticsPopulationManagement = () => {
const [renewalReducedRowData, setRenewalReducedRowData] = useState<Record<string, string | number>[]>([]);
const [renewalAddedRowData, setRenewalAddedRowData] = useState<Record<string, string | number>[]>([]);
/* ── 행정인구현황 데이터 ── */
const [statusRowData, setStatusRowData] = useState<Record<string, string | number>[]>([]);
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<PopulationChartData | null>(null);
/* ── 상위 코드 다이얼로그 ── */
const [dialogOpen, setDialogOpen] = useState(false);
const [candidates, setCandidates] = useState<UpperCodeCandidateItem[]>([]);
@@ -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 = () => {
<input ref={domesticFileInputRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={handleDomesticFileChange} />
<input ref={foreignFileInputRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={handleForeignFileChange} />
<SearchForm sido={false} gugun={false} onSubmit={handleSearch} onReset={handleReset} />
<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={statusOMale} onChange={(e) => setStatusOMale(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={statusOFemale} onChange={(e) => setStatusOFemale(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={statusFMale} onChange={(e) => setStatusFMale(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={statusFFemale} onChange={(e) => setStatusFFemale(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={vizOMale} onChange={(e) => setVizOMale(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={vizOFemale} onChange={(e) => setVizOFemale(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={vizFMale} onChange={(e) => setVizFMale(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={vizFFemale} onChange={(e) => setVizFFemale(e.target.checked)} className="size-4" />
외국인 여성
</label>
<Button type="button" color="primary" onClick={handleVizSelectAll}
className="px-[1rem] py-[0.5rem] text-[1.2rem] shrink-0 whitespace-nowrap">
전체선택
</Button>
<Button type="button" color="primary" onClick={handleVizDeselectAll}
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">
@@ -510,7 +650,7 @@ const StatisticsPopulationManagement = () => {
엑셀 업로드 자료 (내국인 전체 리스트)
</span>
</div>
<div className="flex justify-end mb-[0.4rem]">
<div className="flex justify-end pr-2 mb-[0.4rem]">
<Button type="button" color="red" onClick={() => domesticFileInputRef.current?.click()}
className="mt-[0.5rem] px-[3rem] py-[0.8rem] text-[1.4rem]">
엑셀 업로드
@@ -565,17 +705,19 @@ const StatisticsPopulationManagement = () => {
<TabsContent value="foreign">
<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 mb-[0.4rem]">
<Button type="button" color="red" onClick={() => foreignFileInputRef.current?.click()}
className="mt-[0.5rem] px-[3rem] py-[0.8rem] text-[1.4rem]">
엑셀 업로드
</Button>
</div>
<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>
{/* pr-8 (오른쪽 패딩)을 추가하여 버튼을 왼쪽으로 밀어냅니다 */}
<div className="flex justify-end pr-2 mb-[0.4rem]">
<Button type="button" color="red" onClick={() => foreignFileInputRef.current?.click()}
className="mt-[0.5rem] px-[3rem] py-[0.8rem] text-[1.4rem]">
엑셀 업로드
</Button>
</div>
<DataGrid
columnInfo={foreignLeftColumnInfo}
rowStartNum={ForeignPopulationLeftColumnConfig.rowStartNum}
@@ -624,12 +766,12 @@ const StatisticsPopulationManagement = () => {
<TabsContent value="renewal">
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center mb-[0.4rem]">
<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.9rem]" />
<div className="h-[3.4rem]" />
<DataGrid
columnInfo={renewalReducedColumnInfo}
rowStartNum={AdmdstRenewalReducedColumnConfig.rowStartNum}
@@ -675,10 +817,36 @@ const StatisticsPopulationManagement = () => {
</div>
</TabsContent>
<TabsContent value="status">
<div className="p-6 text-sm text-gray-400">행정인구현황 구현 예정</div>
{/* 그리드 라벨 */}
<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={populationStatusColumnInfo}
rowStartNum={PopulationStatusColumnConfig.rowStartNum}
excelExportFileSuffix="행정인구현황"
rowData={statusRowData}
setRowData={setStatusRowData}
isExcelImport={false}
isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={PopulationStatusColumnConfig.fixedColumns}
enableSort={false}
/>
</TabsContent>
{/* ── 행정인구현황 시각화 탭 ── */}
<TabsContent value="statusViz">
<div className="p-6 text-sm text-gray-400">행정인구현황 시각화 구현 예정</div>
<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={chartData} />
</div>
</TabsContent>
</Tabs>
</div>
@@ -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",
],
};
@@ -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<ChartDataResponse> {
const response = await stClient.post<ChartDataResponse>(
`${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;
}
}