From 58ca85ce90258c0d374942d31083bee99566a3a5 Mon Sep 17 00:00:00 2001 From: yangsein Date: Tue, 16 Jun 2026 16:43:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=EC=9D=B8=EA=B5=AC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=82=B4=EA=B5=AD=EC=9D=B8/=EC=99=B8?= =?UTF-8?q?=EA=B5=AD=EC=9D=B8/=ED=96=89=EC=A0=95=EB=8F=99=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=ED=83=AD=20=EA=B5=AC=ED=98=84"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StatisticsPopulationManagement.tsx | 695 +++++++++++++++++- .../management/UpperCodeSelectDialog.tsx | 118 +++ .../column/management/AdmdstRenewalColumn.ts | 75 ++ .../management/DomesticPopulationColumn.ts | 145 ++++ .../management/ForeignPopulationColumn.ts | 145 ++++ .../api/statistics-ppltn-service.ts | 161 ++++ ...tatistics-domestic-population-interface.ts | 62 ++ apps/st/src/shared/config/api.ts | 1 + 8 files changed, 1397 insertions(+), 5 deletions(-) create mode 100644 apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx create mode 100644 apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts create mode 100644 apps/st/src/components/util/column/management/DomesticPopulationColumn.ts create mode 100644 apps/st/src/components/util/column/management/ForeignPopulationColumn.ts create mode 100644 apps/st/src/entities/statistics/api/statistics-ppltn-service.ts create mode 100644 apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts diff --git a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx index c0c5e0de1..e1d7ecd94 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -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 => { + 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(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[]>([]); + const [domesticRightRowData, setDomesticRightRowData] = useState[]>([]); + + /* ── 외국인 데이터 ── */ + const [foreignLeftRowData, setForeignLeftRowData] = useState[]>([]); + const [foreignRightRowData, setForeignRightRowData] = useState[]>([]); + + /* ── 행정동갱신 데이터 ── */ + const [renewalReducedRowData, setRenewalReducedRowData] = useState[]>([]); + const [renewalAddedRowData, setRenewalAddedRowData] = useState[]>([]); + + /* ── 상위 코드 다이얼로그 ── */ + const [dialogOpen, setDialogOpen] = useState(false); + const [candidates, setCandidates] = useState([]); + const selectedRowIndexRef = useRef(null); + const selectedTabRef = useRef<"domestic" | "foreign" | "renewal">("domestic"); + + const lastSearchedValuesRef = useRef(null); + const domesticFileInputRef = useRef(null); + const foreignFileInputRef = useRef(null); + + /* ════════════════ 내국인 ════════════════ */ + const loadDomesticData = async (values: SearchFormValues, reloadRight = true): Promise => { + 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) => { + 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 => { + 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) => { + 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 => { + 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, + 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, + 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 ( - <> - - +
+ + + + + + {isLoading && ( +
+
+ +
+
+ )} + +
+ void handleActiveTabChange(v)} className="w-full" variant="default"> + + {TAB_LIST.map((tab) => ( + {tab.label} + ))} + + + {/* ── 내국인 탭 ── */} + +
+
+
+ + 엑셀 업로드 자료 (내국인 전체 리스트) + +
+
+ +
+ +
+
+
+ + 미매핑 리스트 (작년 대비) + +
+
+ +
+ { + const rowIndex = domesticRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd); + void handleRightRowClick(rowData, rowIndex, "domestic"); + }} + /> +
+
+
+ + {/* ── 외국인 탭 ── */} + +
+
+
+ + 엑셀 업로드 자료 (외국인 전체 리스트) + +
+
+ +
+ +
+
+
+ + 미매핑 리스트 (작년 대비) + +
+
+ +
+ { + const rowIndex = foreignRightRowData.findIndex((r) => r.admdstCd === rowData.admdstCd); + void handleRightRowClick(rowData, rowIndex, "foreign"); + }} + /> +
+
+
+ + +
+
+
+ + 감축된 행정구역(범위:동읍면) 자료 + +
+
+ +
+
+
+ + 추가된 행정구역 자료 + +
+
+ +
+ { + const rowIndex = renewalAddedRowData.findIndex((r) => r.adstCd === rowData.adstCd); + void handleRenewalRowClick(rowData, rowIndex); + }} + /> +
+
+ + +
행정인구현황 구현 예정
+
+ +
행정인구현황 시각화 구현 예정
+
+ +
+ + +
); }; -export default StatisticsPopulationManagement; +export default StatisticsPopulationManagement; \ No newline at end of file diff --git a/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx new file mode 100644 index 000000000..52355304f --- /dev/null +++ b/apps/st/src/components/statistics/management/UpperCodeSelectDialog.tsx @@ -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(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 ( + + + + 상위 코드 리스트 + + + {/* 테이블 */} +
+ + + + + + + +
+ 코드 + + 동명 +
+ {/* 스크롤 영역 분리 */} +
+ + + {candidates.length === 0 ? ( + + + + ) : ( + candidates.map((item) => ( + setSelectedCode(item.adstCd)} + className={`cursor-pointer border-b border-gray-200 transition-colors + ${selectedCode === item.adstCd + ? "bg-blue-100" + : "hover:bg-gray-50" + }`} + > + + + + )) + )} + +
+ 후보 코드가 없습니다. +
+ {item.adstCd} + + {item.adstDstrNm} +
+
+
+ + + + + +
+
+ ); +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts b/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts new file mode 100644 index 000000000..3e01e91ed --- /dev/null +++ b/apps/st/src/components/util/column/management/AdmdstRenewalColumn.ts @@ -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"], +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts b/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts new file mode 100644 index 000000000..00dafba70 --- /dev/null +++ b/apps/st/src/components/util/column/management/DomesticPopulationColumn.ts @@ -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"], + }, + ], +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts b/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts new file mode 100644 index 000000000..0e924f39b --- /dev/null +++ b/apps/st/src/components/util/column/management/ForeignPopulationColumn.ts @@ -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"], + }, + ], +}; \ 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 new file mode 100644 index 000000000..b4eb5a0aa --- /dev/null +++ b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts @@ -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 { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-list`, + { params: { statsYr, opt1 } }, + ); + return response.data; + } + + /** 내국인 최종 마스터 반영 */ + public async saveDomesticPopulationFinal( + statsYr: string, + regList: DomesticPopulationMappingItem[], + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/domestic-population-final`, + { YR: statsYr, regList }, + ); + return response.data; + } + + /** 미매핑 행 클릭 시 상위 코드 후보 조회 */ + public async getUpperCodesByName( + statsYr: string, + admdstNm: string, + ): Promise { + const response = await stClient.post( + `${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 { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/upper-codes-by-name`, + { statsYr, admdstNm }, + ); + return response.data; + } +} + + + +export const statisticsPpltnApi = new StatisticsPpltnApiService(); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts b/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts new file mode 100644 index 000000000..0325f6cf9 --- /dev/null +++ b/apps/st/src/entities/statistics/model/ppltn/statistics-domestic-population-interface.ts @@ -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; +export type DomesticPopulationFinalResponse = ApiResponse; +export type UpperCodesByNameResponse = ApiResponse; \ No newline at end of file diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index c7e0afcf6..dfc788394 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -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",