feat: 통계인구관리 내국인/외국인/행정동갱신 탭 구현"

This commit is contained in:
yangsein
2026-06-16 16:43:06 +09:00
parent 7483d351c3
commit 58ca85ce90
8 changed files with 1397 additions and 5 deletions
@@ -1,11 +1,696 @@
import SearchForm from "@/components/module/SearchForm";
import { useRef, useState } from "react";
import * as XLSX from "xlsx";
import { useAlertStore } from "@/shared/lib";
import { Button, LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components";
import SearchForm from "@/components/module/SearchForm";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
import type { SearchFormValues } from "@/components/type/SearchForm.type";
import { toGridRows } from "@/components/util/grid/gridFunction";
import {
DomesticPopulationLeftColumnConfig,
DomesticPopulationRightColumnConfig,
} from "@/components/util/column/management/DomesticPopulationColumn";
import {
ForeignPopulationLeftColumnConfig,
ForeignPopulationRightColumnConfig,
} from "@/components/util/column/management/ForeignPopulationColumn";
import {
AdmdstRenewalReducedColumnConfig,
AdmdstRenewalAddedColumnConfig,
} from "@/components/util/column/management/AdmdstRenewalColumn";
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";
const TAB_LIST = [
{ key: "domestic", label: "행정인구등록" },
{ key: "foreign", label: "외국인인구등록" },
{ key: "renewal", label: "행정동갱신" },
{ key: "status", label: "행정인구현황" },
{ key: "statusViz", label: "행정인구현황 시각화" },
];
/* ── 컬럼 설정 → ColumnInfo 변환 ── */
const domesticLeftColumnInfo: ColumnInfo = {
columns: DomesticPopulationLeftColumnConfig.columns ?? [],
columnLayout: DomesticPopulationLeftColumnConfig.columnLayout ?? [],
};
const domesticRightColumnInfo: ColumnInfo = {
columns: DomesticPopulationRightColumnConfig.columns ?? [],
columnLayout: DomesticPopulationRightColumnConfig.columnLayout ?? [],
};
const foreignLeftColumnInfo: ColumnInfo = {
columns: ForeignPopulationLeftColumnConfig.columns ?? [],
columnLayout: ForeignPopulationLeftColumnConfig.columnLayout ?? [],
};
const foreignRightColumnInfo: ColumnInfo = {
columns: ForeignPopulationRightColumnConfig.columns ?? [],
columnLayout: ForeignPopulationRightColumnConfig.columnLayout ?? [],
};
const renewalReducedColumnInfo: ColumnInfo = {
columns: AdmdstRenewalReducedColumnConfig.columns ?? [],
columnLayout: AdmdstRenewalReducedColumnConfig.columnLayout ?? [],
};
const renewalAddedColumnInfo: ColumnInfo = {
columns: AdmdstRenewalAddedColumnConfig.columns ?? [],
columnLayout: AdmdstRenewalAddedColumnConfig.columnLayout ?? [],
};
/* ── 엑셀 헤더 검증 ── */
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);
});
};
/* ══════════════════════════════════════════
컴포넌트
══════════════════════════════════════════ */
const StatisticsPopulationManagement = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const [activeTab, setActiveTab] = useState("domestic");
const [isLoading, setIsLoading] = useState(false);
/* ── 내국인 데이터 ── */
const [domesticLeftRowData, setDomesticLeftRowData] = useState<Record<string, string | number>[]>([]);
const [domesticRightRowData, setDomesticRightRowData] = useState<Record<string, string | number>[]>([]);
/* ── 외국인 데이터 ── */
const [foreignLeftRowData, setForeignLeftRowData] = useState<Record<string, string | number>[]>([]);
const [foreignRightRowData, setForeignRightRowData] = useState<Record<string, string | number>[]>([]);
/* ── 행정동갱신 데이터 ── */
const [renewalReducedRowData, setRenewalReducedRowData] = useState<Record<string, string | number>[]>([]);
const [renewalAddedRowData, setRenewalAddedRowData] = useState<Record<string, string | number>[]>([]);
/* ── 상위 코드 다이얼로그 ── */
const [dialogOpen, setDialogOpen] = useState(false);
const [candidates, setCandidates] = useState<UpperCodeCandidateItem[]>([]);
const selectedRowIndexRef = useRef<number | null>(null);
const selectedTabRef = useRef<"domestic" | "foreign" | "renewal">("domestic");
const lastSearchedValuesRef = useRef<SearchFormValues | null>(null);
const domesticFileInputRef = useRef<HTMLInputElement>(null);
const foreignFileInputRef = useRef<HTMLInputElement>(null);
/* ════════════════ 내국인 ════════════════ */
const loadDomesticData = async (values: SearchFormValues, reloadRight = true): Promise<boolean> => {
const yr = values.year ?? "";
if (!yr) { alert("연도를 선택해주세요."); return false; }
try {
setIsLoading(true);
const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1");
if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? []));
if (reloadRight) {
const rightRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "2");
if (rightRes.status === "success") setDomesticRightRowData(toGridRows((rightRes.data as object[]) ?? []));
}
return true;
} catch {
alert("서버 연결 중 오류가 발생했습니다.");
return false;
} finally {
setIsLoading(false);
}
};
const handleDomesticFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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, ["행정기관코드", "행정기관"]);
if (!isValidHeader) { alert("행정인구(내국인) 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요."); e.target.value = ""; return; }
const yr = lastSearchedValuesRef.current?.year ?? "";
if (!yr) { alert("연도를 선택해주세요."); e.target.value = ""; return; }
try {
setIsLoading(true);
const res = await statisticsPpltnApi.uploadDomesticPopulationExcel(file, yr);
if (res.status === "success") {
await loadDomesticData(lastSearchedValuesRef.current!, true);
alert("업로드가 완료되었습니다.");
} else {
alert(res.message || "업로드 중 오류가 발생했습니다.");
}
} catch {
alert("업로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
e.target.value = "";
}
};
const handleDomesticFinal = async () => {
const values = lastSearchedValuesRef.current;
const yr = values?.year ?? "";
if (!yr) { alert("먼저 조회를 진행해주세요."); return; }
if (domesticRightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; }
try {
setIsLoading(true);
const regList = domesticRightRowData
.filter((row) => String(row.mpinCd ?? "").trim() !== "")
.map((row) => ({
statsYr: yr,
admdstCd: String(row.admdstCd ?? ""),
admdstNm: String(row.admdstNm ?? ""),
subAdmdstCd: String(row.mpinCd ?? ""),
}));
const res = await statisticsPpltnApi.saveDomesticPopulationFinal(yr, regList);
if (res.status === "success") {
const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1");
if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? []));
alert("반영이 완료되었습니다.");
} else {
alert(res.message || "반영 중 오류가 발생했습니다.");
}
} catch {
alert("반영 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
/* ════════════════ 외국인 ════════════════ */
const loadForeignData = async (values: SearchFormValues, reloadRight = true): Promise<boolean> => {
const yr = values.year ?? "";
if (!yr) { alert("연도를 선택해주세요."); return false; }
try {
setIsLoading(true);
const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1");
if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? []));
if (reloadRight) {
const rightRes = await statisticsPpltnApi.getForeignPopulationList(yr, "2");
if (rightRes.status === "success") setForeignRightRowData(toGridRows((rightRes.data as object[]) ?? []));
}
return true;
} catch {
alert("서버 연결 중 오류가 발생했습니다.");
return false;
} finally {
setIsLoading(false);
}
};
const handleForeignFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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, ["행정동코드", "행정동명", "외국인수"]);
if (!isValidHeader) { alert("외국인 인구 전용 파일만 업로드 가능합니다.\n올바른 파일을 선택해주세요."); e.target.value = ""; return; }
const yr = lastSearchedValuesRef.current?.year ?? "";
if (!yr) { alert("연도를 선택해주세요."); e.target.value = ""; return; }
try {
setIsLoading(true);
const res = await statisticsPpltnApi.uploadForeignPopulationExcel(file, yr);
if (res.status === "success") {
await loadForeignData(lastSearchedValuesRef.current!, true);
alert("업로드가 완료되었습니다.");
} else {
alert(res.message || "업로드 중 오류가 발생했습니다.");
}
} catch {
alert("업로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
e.target.value = "";
}
};
const handleForeignFinal = async () => {
const values = lastSearchedValuesRef.current;
const yr = values?.year ?? "";
if (!yr) { alert("먼저 조회를 진행해주세요."); return; }
if (foreignRightRowData.length === 0) { alert("반영할 데이터가 없습니다."); return; }
try {
setIsLoading(true);
const regList = foreignRightRowData
.filter((row) => String(row.mpinCd ?? "").trim() !== "")
.map((row) => ({
statsYr: yr,
admdstCd: String(row.admdstCd ?? ""),
admdstNm: String(row.admdstNm ?? ""),
subAdmdstCd: String(row.mpinCd ?? ""),
}));
const res = await statisticsPpltnApi.saveForeignPopulationFinal(yr, regList);
if (res.status === "success") {
const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1");
if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? []));
alert("반영이 완료되었습니다.");
} else {
alert(res.message || "반영 중 오류가 발생했습니다.");
}
} catch {
alert("반영 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
/* ════════════════ 행정동갱신 ════════════════ */
const loadRenewalData = async (values: SearchFormValues): Promise<boolean> => {
const yr = values.year ?? "";
if (!yr) { alert("연도를 선택해주세요."); return false; }
try {
setIsLoading(true);
// OPT_1: "1" = 추가된 행정구역, "2" = 감축된 행정구역
const [addedRes, reducedRes] = await Promise.all([
statisticsPpltnApi.searchChangedArea(yr, "1"),
statisticsPpltnApi.searchChangedArea(yr, "2"),
]);
if (addedRes.status === "success") setRenewalAddedRowData(toGridRows((addedRes.data as object[]) ?? []));
if (reducedRes.status === "success") setRenewalReducedRowData(toGridRows((reducedRes.data as object[]) ?? []));
return true;
} catch {
alert("서버 연결 중 오류가 발생했습니다.");
return false;
} finally {
setIsLoading(false);
}
};
const handleRenewalFinal = async () => {
const values = lastSearchedValuesRef.current;
if (!values?.year) { alert("먼저 조회를 진행해주세요."); return; }
if (renewalReducedRowData.length === 0 && renewalAddedRowData.length === 0) {
alert("반영할 데이터가 없습니다.");
return;
}
try {
setIsLoading(true);
const userInfo = await getUserInfo();
// adstDstrNm("시도 구군 동" 형태)을 sido/gugun/adstDong으로 분리
const addedList = renewalAddedRowData.map((row) => {
const fullNm = String(row.adstDstrNm ?? "").trim();
const parts = fullNm.split(/\s+/);
const sido = parts[0] ?? "";
const gugun = parts.length >= 2 ? parts[1] : "";
const adstDong = parts.length >= 3 ? parts.slice(2).join(" ") : "";
return {
adstCd: String(row.adstCd ?? ""),
lawCd: String(row.lawCd ?? ""),
sido,
gugun,
adstDong,
userId: userInfo.userUnqId,
};
});
const reducedList = renewalReducedRowData.map((row) => ({
adstCd: String(row.adstCd ?? ""),
}));
const res = await statisticsPpltnApi.saveChangedArea(reducedList, addedList);
if (res.status === "success") {
await loadRenewalData(values);
alert("행정구역 감축 및 추가사항이 성공적으로 반영되었습니다.");
} else {
alert(res.message || "반영 중 오류가 발생했습니다.");
}
} catch {
alert("반영 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
/* ════════════════ 공통 ════════════════ */
const handleSearch = async (values: SearchFormValues) => {
lastSearchedValuesRef.current = values;
if (activeTab === "domestic") {
const yr = values.year ?? "";
try {
setIsLoading(true);
const leftRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "1");
if (leftRes.status === "success") setDomesticLeftRowData(toGridRows((leftRes.data as object[]) ?? []));
if (domesticRightRowData.length === 0) {
const rightRes = await statisticsPpltnApi.getDomesticPopulationList(yr, "2");
if (rightRes.status === "success") setDomesticRightRowData(toGridRows((rightRes.data as object[]) ?? []));
}
} catch {
alert("서버 연결 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
} else if (activeTab === "foreign") {
const yr = values.year ?? "";
try {
setIsLoading(true);
const leftRes = await statisticsPpltnApi.getForeignPopulationList(yr, "1");
if (leftRes.status === "success") setForeignLeftRowData(toGridRows((leftRes.data as object[]) ?? []));
if (foreignRightRowData.length === 0) {
const rightRes = await statisticsPpltnApi.getForeignPopulationList(yr, "2");
if (rightRes.status === "success") setForeignRightRowData(toGridRows((rightRes.data as object[]) ?? []));
}
} catch {
alert("서버 연결 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
} else if (activeTab === "renewal") {
await loadRenewalData(values);
}
};
const handleReset = () => {
setDomesticLeftRowData([]);
setDomesticRightRowData([]);
setForeignLeftRowData([]);
setForeignRightRowData([]);
setRenewalReducedRowData([]);
setRenewalAddedRowData([]);
setActiveTab("domestic");
lastSearchedValuesRef.current = null;
};
const handleActiveTabChange = async (tabKey: string) => {
setActiveTab(tabKey);
const values = lastSearchedValuesRef.current;
if (!values) return;
if (tabKey === "domestic" && domesticLeftRowData.length === 0) await loadDomesticData(values);
if (tabKey === "foreign" && foreignLeftRowData.length === 0) await loadForeignData(values);
if (tabKey === "renewal" && renewalReducedRowData.length === 0 && renewalAddedRowData.length === 0) await loadRenewalData(values);
};
/* ── 우측 행 클릭 → 상위 코드 후보 조회 ── */
const handleRightRowClick = async (
rowData: Record<string, string | number>,
rowIndex: number,
tab: "domestic" | "foreign",
) => {
const yr = lastSearchedValuesRef.current?.year ?? "";
const admdstNm = String(rowData.admdstNm ?? "");
if (!admdstNm) { alert("행정구역명이 없습니다."); return; }
try {
setIsLoading(true);
const res = await statisticsPpltnApi.getUpperCodesByName(yr, admdstNm);
if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) {
setCandidates(res.data);
selectedRowIndexRef.current = rowIndex;
selectedTabRef.current = tab;
setDialogOpen(true);
} else {
alert("해당 지역명에 대한 상위 코드 후보가 없습니다.");
}
} catch {
alert("상위 코드 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
/* ── 행정동갱신 우측 행 클릭 → 법정동코드 후보 조회 ── */
const handleRenewalRowClick = async (
rowData: Record<string, string | number>,
rowIndex: number,
) => {
const yr = lastSearchedValuesRef.current?.year ?? "";
const adstDstrNm = String(rowData.adstDstrNm ?? "");
if (!adstDstrNm) { alert("행정구역명이 없습니다."); return; }
try {
setIsLoading(true);
const res = await statisticsPpltnApi.getRenewalUpperCodesByName(yr, adstDstrNm);
if (res.status === "success" && Array.isArray(res.data) && res.data.length > 0) {
setCandidates(res.data);
selectedRowIndexRef.current = rowIndex;
selectedTabRef.current = "renewal";
setDialogOpen(true);
} else {
alert("해당 지역명에 대한 법정동코드 후보가 없습니다.");
}
} catch {
alert("법정동코드 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
/* ── 다이얼로그 코드 선택 → 업데이트 ── */
const handleCodeSelect = (item: UpperCodeCandidateItem) => {
const rowIndex = selectedRowIndexRef.current;
if (rowIndex === null) return;
if (selectedTabRef.current === "renewal") {
// 행정동갱신: 법정동코드/법정동명 업데이트
setRenewalAddedRowData((prev) =>
prev.map((row, idx) =>
idx === rowIndex
? { ...row, lawCd: item.adstCd, lawNm: item.adstDstrNm }
: row
)
);
} else {
// 내국인/외국인: mpinCd 업데이트
const setter = selectedTabRef.current === "domestic" ? setDomesticRightRowData : setForeignRightRowData;
setter((prev) =>
prev.map((row, idx) =>
idx === rowIndex ? { ...row, mpinCd: item.adstCd } : row
)
);
}
selectedRowIndexRef.current = null;
};
/* ══════════════════════ 렌더 ══════════════════════ */
return (
<>
<SearchForm sido={false} />
</>
<div className="relative">
<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} />
{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">
<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={() => domesticFileInputRef.current?.click()}
className="mt-[0.5rem] px-[3rem] py-[0.8rem] text-[1.4rem]">
엑셀 업로드
</Button>
</div>
<DataGrid
columnInfo={domesticLeftColumnInfo}
rowStartNum={DomesticPopulationLeftColumnConfig.rowStartNum}
excelExportFileSuffix="내국인전체리스트"
rowData={domesticLeftRowData}
setRowData={setDomesticLeftRowData}
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={isLoading || domesticRightRowData.length === 0}
onClick={() => void handleDomesticFinal()}
className="mt-[0.5rem] px-[4rem] py-[0.8rem] text-[1.4rem]">
반영
</Button>
</div>
<DataGrid
columnInfo={domesticRightColumnInfo}
rowStartNum={DomesticPopulationRightColumnConfig.rowStartNum}
excelExportFileSuffix="미매핑리스트"
rowData={domesticRightRowData}
setRowData={setDomesticRightRowData}
isExcelImport={false} isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={DomesticPopulationRightColumnConfig.fixedColumns}
enableSort={false}
onRowClick={(rowData) => {
const rowIndex = domesticRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd);
void handleRightRowClick(rowData, rowIndex, "domestic");
}}
/>
</div>
</div>
</TabsContent>
{/* ── 외국인 탭 ── */}
<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>
<DataGrid
columnInfo={foreignLeftColumnInfo}
rowStartNum={ForeignPopulationLeftColumnConfig.rowStartNum}
excelExportFileSuffix="외국인전체리스트"
rowData={foreignLeftRowData}
setRowData={setForeignLeftRowData}
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={isLoading || foreignRightRowData.length === 0}
onClick={() => void handleForeignFinal()}
className="mt-[0.5rem] px-[4rem] py-[0.8rem] text-[1.4rem]">
반영
</Button>
</div>
<DataGrid
columnInfo={foreignRightColumnInfo}
rowStartNum={ForeignPopulationRightColumnConfig.rowStartNum}
excelExportFileSuffix="외국인미매핑리스트"
rowData={foreignRightRowData}
setRowData={setForeignRightRowData}
isExcelImport={false} isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={ForeignPopulationRightColumnConfig.fixedColumns}
enableSort={false}
onRowClick={(rowData) => {
const rowIndex = foreignRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd);
void handleRightRowClick(rowData, rowIndex, "foreign");
}}
/>
</div>
</div>
</TabsContent>
<TabsContent value="renewal">
<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="h-[3.9rem]" />
<DataGrid
columnInfo={renewalReducedColumnInfo}
rowStartNum={AdmdstRenewalReducedColumnConfig.rowStartNum}
excelExportFileSuffix="감축된행정구역"
rowData={renewalReducedRowData}
setRowData={setRenewalReducedRowData}
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={isLoading || (renewalReducedRowData.length === 0 && renewalAddedRowData.length === 0)}
onClick={() => void handleRenewalFinal()}
className="mt-[0.5rem] px-[4rem] py-[0.8rem] text-[1.4rem]">
반영
</Button>
</div>
<DataGrid
columnInfo={renewalAddedColumnInfo}
rowStartNum={AdmdstRenewalAddedColumnConfig.rowStartNum}
excelExportFileSuffix="추가된행정구역"
rowData={renewalAddedRowData}
setRowData={setRenewalAddedRowData}
isExcelImport={false} isExcelExport={false}
showHeaderCheckbox={false}
fixedColumns={AdmdstRenewalAddedColumnConfig.fixedColumns}
enableSort={false}
onRowClick={(rowData) => {
const rowIndex = renewalAddedRowData.findIndex((r) => r.adstCd === rowData.adstCd);
void handleRenewalRowClick(rowData, rowIndex);
}}
/>
</div>
</div>
</TabsContent>
<TabsContent value="status">
<div className="p-6 text-sm text-gray-400">행정인구현황 구현 예정</div>
</TabsContent>
<TabsContent value="statusViz">
<div className="p-6 text-sm text-gray-400">행정인구현황 시각화 구현 예정</div>
</TabsContent>
</Tabs>
</div>
<UpperCodeSelectDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
candidates={candidates}
onSelect={handleCodeSelect}
/>
</div>
);
};
export default StatisticsPopulationManagement;
export default StatisticsPopulationManagement;
@@ -0,0 +1,118 @@
import { useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface";
interface UpperCodeSelectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
candidates: UpperCodeCandidateItem[];
onSelect: (item: UpperCodeCandidateItem) => void;
}
export const UpperCodeSelectDialog = ({
open,
onOpenChange,
candidates,
onSelect,
}: UpperCodeSelectDialogProps) => {
const [selectedCode, setSelectedCode] = useState<string | null>(null);
const handleSelect = () => {
const item = candidates.find((c) => c.adstCd === 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>
</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) => (
<tr
key={item.adstCd}
onClick={() => setSelectedCode(item.adstCd)}
className={`cursor-pointer border-b border-gray-200 transition-colors
${selectedCode === item.adstCd
? "bg-blue-100"
: "hover:bg-gray-50"
}`}
>
<td className="py-[0.7rem] px-[1rem] text-center border-r border-gray-200 w-[40%]">
{item.adstCd}
</td>
<td className="py-[0.7rem] px-[1rem] text-center">
{item.adstDstrNm}
</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,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,145 @@
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,
fixedColumns: 2,
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: 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",
"mpinCd",
{
name: "populationGroup",
header: { text: "인구수" },
items: ["domplWholPpltnCnt", "domplMlPpltnCnt", "domplFmlPpltnCnt"],
},
],
};
@@ -0,0 +1,145 @@
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: 2,
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: 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",
"mpinCd",
{
name: "populationGroup",
header: { text: "외국인수" },
items: ["frgnrWholPpltnCnt", "frgnrMlPpltnCnt", "frgnrFmlPpltnCnt"],
},
],
};
@@ -0,0 +1,161 @@
import { ENDPOINT } from "@/shared/config";
import { stClient } from "@/shared/api/client";
import type {
DomesticPopulationListResponse,
DomesticPopulationFinalResponse,
UpperCodesByNameResponse,
DomesticPopulationMappingItem,
} from "../model/ppltn/statistics-domestic-population-interface";
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,
): Promise<UpperCodesByNameResponse> {
const response = await stClient.post<UpperCodesByNameResponse>(
`${ENDPOINT.ST_SERVICE.PPLTN}/upper-codes-by-name`,
{ statsYr, admdstNm },
);
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.post<UpperCodesByNameResponse>(
`${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/upper-codes-by-name`,
{ statsYr, admdstNm },
);
return response.data;
}
}
export const statisticsPpltnApi = new StatisticsPpltnApiService();
@@ -0,0 +1,62 @@
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 {
adstCd: string; // 행정구역코드
adstDstrNm: string; // 행정구역명
}
/* ── API 응답 타입 ── */
export type DomesticPopulationListResponse = ApiResponse<DomesticPopulationItem[]>;
export type DomesticPopulationFinalResponse = ApiResponse<string>;
export type UpperCodesByNameResponse = ApiResponse<UpperCodeCandidateItem[]>;
+1
View File
@@ -71,6 +71,7 @@ export const ENDPOINT = {
INPUT: "/st-input-service/api/v1/input",
REVIEW: "/st-management-service/api/v1/review",
STATS: "/st-management-service/api/stats",
PPLTN: "/st-management-service/api/v1/ppltn",
INPUTCOMMON: "/st-input-service/api/v1/common",
INPUTVERIFY: "/st-input-service/api/v1/verify",
TOTAL: "/st-management-service/api/v1/stats",