From 58ca85ce90258c0d374942d31083bee99566a3a5 Mon Sep 17 00:00:00 2001 From: yangsein Date: Tue, 16 Jun 2026 16:43:06 +0900 Subject: [PATCH 01/50] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=EC=9D=B8?= =?UTF-8?q?=EA=B5=AC=EA=B4=80=EB=A6=AC=20=EB=82=B4=EA=B5=AD=EC=9D=B8/?= =?UTF-8?q?=EC=99=B8=EA=B5=AD=EC=9D=B8/=ED=96=89=EC=A0=95=EB=8F=99?= =?UTF-8?q?=EA=B0=B1=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", From 82ab1376cc002e0bf3a917db48c61d5bb596a982 Mon Sep 17 00:00:00 2001 From: yangsein Date: Thu, 18 Jun 2026 15:38:10 +0900 Subject: [PATCH 02/50] =?UTF-8?q?feat:=ED=96=89=EC=A0=95=EB=8F=99=20?= =?UTF-8?q?=ED=83=AD=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/PopulationStatusChart.tsx | 106 +++++++++ .../StatisticsPopulationManagement.tsx | 202 ++++++++++++++++-- .../management/PopulationStatusColumn.ts | 121 +++++++++++ .../api/statistics-ppltn-service.ts | 60 ++++++ 4 files changed, 472 insertions(+), 17 deletions(-) create mode 100644 apps/st/src/components/statistics/management/PopulationStatusChart.tsx create mode 100644 apps/st/src/components/util/column/management/PopulationStatusColumn.ts diff --git a/apps/st/src/components/statistics/management/PopulationStatusChart.tsx b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx new file mode 100644 index 000000000..1b7198135 --- /dev/null +++ b/apps/st/src/components/statistics/management/PopulationStatusChart.tsx @@ -0,0 +1,106 @@ +import { Chart } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + Title, + Tooltip, + Legend, +} from "chart.js"; +import type { ChartOptions } from "chart.js"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend); + +// 차트 설정 외부 정의 (Mixed 타입 명시) +const options: ChartOptions<"bar" | "line"> = { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { stacked: true }, + y: { + stacked: true, + beginAtZero: true, + title: { display: true, text: "인구수(명)" }, + }, + y1: { + position: "right", + beginAtZero: true, + grid: { drawOnChartArea: false }, + title: { display: true, text: "총 인구(명)" }, + }, + }, + plugins: { + legend: { position: "bottom" }, + }, +}; + +export type PopulationChartData = { + X_AXIS: string[]; + TOT: number[]; + ORIGIN_MALE: (number | null)[]; + ORIGIN_FEMALE: (number | null)[]; + FOREIGN_MALE: (number | null)[]; + FOREIGN_FEMALE: (number | null)[]; +}; + +type Props = { + chartData: PopulationChartData | null; +}; + +const SERIES_META = [ + { key: "ORIGIN_MALE", name: "내국인 남성", color: "#3B82F6" }, + { key: "ORIGIN_FEMALE", name: "내국인 여성", color: "#EC4899" }, + { key: "FOREIGN_MALE", name: "외국인 남성", color: "#10B981" }, + { key: "FOREIGN_FEMALE", name: "외국인 여성", color: "#F59E0B" }, +] as const; + +const PopulationStatusChart = ({ chartData }: Props) => { + if (!chartData || chartData.X_AXIS.length === 0) { + return ( +
+ 조회된 데이터가 없습니다. 조회 버튼을 눌러주세요. +
+ ); + } + + const data = { + labels: chartData.X_AXIS.map((yr) => `${yr}년`), + datasets: [ + ...SERIES_META + .filter((s) => chartData[s.key]?.some((v) => v !== null)) + .map((s) => ({ + type: "bar" as const, + label: s.name, + data: chartData[s.key].map((v) => v ?? 0), + backgroundColor: s.color, + stack: "stack1", + })), + { + type: "line" as const, + label: "총 인구", + data: chartData.TOT, + borderColor: "#1F2937", + backgroundColor: "#1F2937", + borderDash: [5, 5], + tension: 0.1, + yAxisID: "y1", + }, + ], + }; + + return ( +
+ {/* 제네릭 타입을 통해 Mixed Chart 지원, 부모 컨테이너 크기에 맞춰 반응형으로 리사이즈됨 */} + + type="bar" + data={data} + options={options} + /> +
+ ); +}; + +export default PopulationStatusChart; diff --git a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx index e1d7ecd94..5c2f0cd0f 100644 --- a/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx +++ b/apps/st/src/components/statistics/management/StatisticsPopulationManagement.tsx @@ -21,10 +21,12 @@ import { AdmdstRenewalReducedColumnConfig, AdmdstRenewalAddedColumnConfig, } from "@/components/util/column/management/AdmdstRenewalColumn"; +import { PopulationStatusColumnConfig } from "@/components/util/column/management/PopulationStatusColumn"; import { statisticsPpltnApi } from "@/entities/statistics/api/statistics-ppltn-service"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; import type { UpperCodeCandidateItem } from "@/entities/statistics/model/ppltn/statistics-domestic-population-interface"; import { UpperCodeSelectDialog } from "./UpperCodeSelectDialog"; +import PopulationStatusChart, { type PopulationChartData } from "./PopulationStatusChart"; const TAB_LIST = [ { key: "domestic", label: "행정인구등록" }, @@ -59,6 +61,10 @@ const renewalAddedColumnInfo: ColumnInfo = { columns: AdmdstRenewalAddedColumnConfig.columns ?? [], columnLayout: AdmdstRenewalAddedColumnConfig.columnLayout ?? [], }; +const populationStatusColumnInfo: ColumnInfo = { + columns: PopulationStatusColumnConfig.columns ?? [], + columnLayout: PopulationStatusColumnConfig.columnLayout ?? [], +}; /* ── 엑셀 헤더 검증 ── */ const validateExcelHeader = (file: File, requiredKeywords: string[]): Promise => { @@ -107,6 +113,21 @@ const StatisticsPopulationManagement = () => { const [renewalReducedRowData, setRenewalReducedRowData] = useState[]>([]); const [renewalAddedRowData, setRenewalAddedRowData] = useState[]>([]); + /* ── 행정인구현황 데이터 ── */ + const [statusRowData, setStatusRowData] = useState[]>([]); + const [statusOMale, setStatusOMale] = useState(true); + const [statusOFemale, setStatusOFemale] = useState(true); + const [statusFMale, setStatusFMale] = useState(true); + const [statusFFemale, setStatusFFemale] = useState(true); + + /* ── 행정인구현황 시각화 데이터 ── */ + const [vizYear, setVizYear] = useState(String(new Date().getFullYear() - 1)); + const [vizOMale, setVizOMale] = useState(true); + const [vizOFemale, setVizOFemale] = useState(true); + const [vizFMale, setVizFMale] = useState(true); + const [vizFFemale, setVizFFemale] = useState(true); + const [chartData, setChartData] = useState(null); + /* ── 상위 코드 다이얼로그 ── */ const [dialogOpen, setDialogOpen] = useState(false); const [candidates, setCandidates] = useState([]); @@ -340,6 +361,38 @@ const StatisticsPopulationManagement = () => { } }; + /* ════════════════ 행정인구현황 시각화 ════════════════ */ + const handleVizSearch = async (yr: string) => { + if (!yr) { alert("연도를 선택해주세요."); return; } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.getChartDatas1(yr, vizOMale, vizOFemale, vizFMale, vizFFemale); + if (res.status === "success") { + setChartData(res.data as PopulationChartData); + } else { + alert(res.message || "조회 중 오류가 발생했습니다."); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleVizSelectAll = () => { + setVizOMale(true); + setVizOFemale(true); + setVizFMale(true); + setVizFFemale(true); + }; + + const handleVizDeselectAll = () => { + setVizOMale(false); + setVizOFemale(false); + setVizFMale(false); + setVizFFemale(false); + }; + /* ════════════════ 공통 ════════════════ */ const handleSearch = async (values: SearchFormValues) => { lastSearchedValuesRef.current = values; @@ -375,6 +428,32 @@ const StatisticsPopulationManagement = () => { } } else if (activeTab === "renewal") { await loadRenewalData(values); + } else if (activeTab === "status") { + const yr = values.year ?? ""; + if (!yr) { alert("연도를 선택해주세요."); return; } + if (!statusOMale && !statusOFemale && !statusFMale && !statusFFemale) { + alert("체크 박스를 모두 해제하신 상태에서는\n조회가 불가능합니다.\n(최소 1개 이상의 체크 박스 선택 후\n조회를 진행해주세요.)"); + return; + } + try { + setIsLoading(true); + const res = await statisticsPpltnApi.searchPopByEachCondition( + yr, statusOMale, statusOFemale, statusFMale, statusFFemale, + ); + if (res.status === "success") { + setStatusRowData(toGridRows((res.data as object[]) ?? [])); + } else { + alert(res.message || "조회 중 오류가 발생했습니다."); + } + } catch { + alert("서버 연결 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } else if (activeTab === "statusViz") { + const yr = values.year ?? ""; + setVizYear(yr); + await handleVizSearch(yr); } }; @@ -385,6 +464,17 @@ const StatisticsPopulationManagement = () => { setForeignRightRowData([]); setRenewalReducedRowData([]); setRenewalAddedRowData([]); + setStatusRowData([]); + setStatusOMale(true); + setStatusOFemale(true); + setStatusFMale(true); + setStatusFFemale(true); + setVizYear(String(new Date().getFullYear() - 1)); + setVizOMale(true); + setVizOFemale(true); + setVizFMale(true); + setVizFFemale(true); + setChartData(null); setActiveTab("domestic"); lastSearchedValuesRef.current = null; }; @@ -483,7 +573,57 @@ const StatisticsPopulationManagement = () => { - +
+ + {activeTab === "status" && ( +
+ + + + +
+ )} + {activeTab === "statusViz" && ( +
+ + + + + + +
+ )} +
{isLoading && (
@@ -510,7 +650,7 @@ const StatisticsPopulationManagement = () => { 엑셀 업로드 자료 (내국인 전체 리스트)
-
+
-
+
+ + 엑셀 업로드 자료 (외국인 전체 리스트) + +
+ + {/* pr-8 (오른쪽 패딩)을 추가하여 버튼을 왼쪽으로 밀어냅니다 */} +
+ +
{
-
+
감축된 행정구역(범위:동읍면) 자료
-
+
{
-
행정인구현황 구현 예정
+ {/* 그리드 라벨 */} +
+ + 행정 인구 현황 + +
+ +
+ + {/* ── 행정인구현황 시각화 탭 ── */} -
행정인구현황 시각화 구현 예정
+ + 행정인구현황 시각화 (기준연도 ~ 과거 자료(최대 5년) / *단위: 명) + + +
+ +
diff --git a/apps/st/src/components/util/column/management/PopulationStatusColumn.ts b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts new file mode 100644 index 000000000..3ea57358e --- /dev/null +++ b/apps/st/src/components/util/column/management/PopulationStatusColumn.ts @@ -0,0 +1,121 @@ +import { ValueType } from "realgrid"; +import type { ColumnInfoList } from "@/components/type/DataGrid.type"; + +/* ══════════════════════════════════════════════════ + 16. 통계인구관리 > 행정인구현황 탭 컬럼 설정 +══════════════════════════════════════════════════ */ + +export const PopulationStatusColumnConfig: ColumnInfoList = { + tabKey: "POPULATION_STATUS", + tabLabel: "행정 인구 현황", + rowStartNum: 1, + fixedColumns: 3, + columns: [ + { + name: "statsYr", + fieldName: "statsYr", + header: { text: "년도" }, + width: 90, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstCd", + fieldName: "admdstCd", + header: { text: "행정구역코드" }, + width: 140, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "admdstNm", + fieldName: "admdstNm", + header: { text: "행정구역명" }, + width: 200, + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "wholPpltnCnt", + fieldName: "wholPpltnCnt", + header: { text: "총인구" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplWholPpltnCnt", + fieldName: "domplWholPpltnCnt", + header: { text: "내국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplMlPpltnCnt", + fieldName: "domplMlPpltnCnt", + header: { text: "내국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "domplFmlPpltnCnt", + fieldName: "domplFmlPpltnCnt", + header: { text: "내국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrWholPpltnCnt", + fieldName: "frgnrWholPpltnCnt", + header: { text: "외국인 소계" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrMlPpltnCnt", + fieldName: "frgnrMlPpltnCnt", + header: { text: "외국인 남성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + { + name: "frgnrFmlPpltnCnt", + fieldName: "frgnrFmlPpltnCnt", + header: { text: "외국인 여성" }, + width: 110, + valueType: ValueType.NUMBER, + dataFormat: "#,##0", + editable: false, + styleName: "rg-cell-readonly", + }, + ], + columnLayout: [ + "statsYr", + "admdstCd", + "admdstNm", + "wholPpltnCnt", + "domplWholPpltnCnt", + "domplMlPpltnCnt", + "domplFmlPpltnCnt", + "frgnrWholPpltnCnt", + "frgnrMlPpltnCnt", + "frgnrFmlPpltnCnt", + ], +}; \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts index b4eb5a0aa..c4fbec777 100644 --- a/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-ppltn-service.ts @@ -8,6 +8,20 @@ import type { DomesticPopulationMappingItem, } from "../model/ppltn/statistics-domestic-population-interface"; +// 1. 응답 데이터의 타입을 정의합니다 (이미 정의된 것이 있다면 그것을 사용하세요) +interface ChartDataResponse { + status: string; + data: { + X_AXIS: string[]; + TOT: number[]; + ORIGIN_MALE: (number | null)[]; + ORIGIN_FEMALE: (number | null)[]; + FOREIGN_MALE: (number | null)[]; + FOREIGN_FEMALE: (number | null)[]; + }; + message?: string; +} + export class StatisticsPpltnApiService { /** 내국인 엑셀 임시 업로드 */ @@ -154,6 +168,52 @@ export class StatisticsPpltnApiService { ); return response.data; } + + /* ════════════════════════════════════ + 행정인구현황 + ════════════════════════════════════ */ + + /** 행정인구현황 — 체크박스 조건에 따른 인구 현황 조회 */ + public async searchPopByEachCondition( + statsYr: string, + oMale: boolean, + oFemale: boolean, + fMale: boolean, + fFemale: boolean, + ): Promise<{ status: string; data: object[]; message?: string }> { + const response = await stClient.post<{ status: string; data: object[]; message?: string }>( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/pop-by-condition`, + { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + }, + ); + return response.data; + } + +/** 행정인구현황 시각화 — 기준연도 기준 최대 5년치 차트 데이터 조회 */ + public async getChartDatas1( + statsYr: string, + oMale: boolean, + oFemale: boolean, + fMale: boolean, + fFemale: boolean, + ): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.PPLTN}/admdst-renewal/chart-datas`, + { + statsYr, + oMale: oMale ? "TRUE" : "FALSE", + oFemale: oFemale ? "TRUE" : "FALSE", + fMale: fMale ? "TRUE" : "FALSE", + fFemale: fFemale ? "TRUE" : "FALSE", + }, + ); + return response.data; + } } From fb160e712b99fc7ba48241eb22a9a887ab258e13 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 29 Jun 2026 14:46:40 +0900 Subject: [PATCH 03/50] =?UTF-8?q?[as]=20chore:=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/entities/instl-sncn/api/queries.ts | 2 +- apps/as/src/pages/instl-sncn/lib/checklist-utils.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts index 4b5f4dd10..ac651be47 100644 --- a/apps/as/src/entities/instl-sncn/api/queries.ts +++ b/apps/as/src/entities/instl-sncn/api/queries.ts @@ -23,7 +23,7 @@ export const instlSncnKeys = { export function useInstlSncnList(params: InstlSncnMngSearchParams) { return useQuery({ - queryKey: instlSncnKeys.list(), + queryKey: [...instlSncnKeys.list(), params], queryFn: () => instlSncnService.getPage(params), }); } diff --git a/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts index 3508e2263..adeb89308 100644 --- a/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts +++ b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts @@ -69,15 +69,7 @@ export function filterChecklistRows( rows: CklstRvwRsltTreeDto[], taskCode: string | null, ): CklstRvwRsltTreeDto[] { - return rows - .filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)) - .slice() - .sort( - (a, b) => - (a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) || - (a.sortSeq ?? 0) - (b.sortSeq ?? 0) || - (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0), - ); + return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice(); } export function getChecklistDisplayRows( From 20fd291cc3456c5a99a27d4f81ba8540d38b5fa3 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 29 Jun 2026 16:08:21 +0900 Subject: [PATCH 04/50] =?UTF-8?q?[as]=20feat:=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C=20=EC=97=85?= =?UTF-8?q?=EB=AC=B4=EA=B5=AC=EB=B6=84=20=EC=84=A0=ED=83=9D=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../instl-sncn/regist/page.tsx | 13 ++- .../pages/instl-sncn/lib/form-data-mapper.ts | 7 +- .../ui/components/task-se-picker-dialog.tsx | 107 ++++++++++++++++++ .../pages/instl-sncn/ui/instl-form-page.tsx | 5 +- .../as/src/pages/instl-sncn/ui/instl-list.tsx | 38 +++++-- .../pages/instl-sncn/ui/instl-regist-page.tsx | 8 +- .../ui/sections/basic-info-form-section.tsx | 41 ++----- 7 files changed, 171 insertions(+), 48 deletions(-) create mode 100644 apps/as/src/pages/instl-sncn/ui/components/task-se-picker-dialog.tsx diff --git a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx index b19f7abb6..476d0b7c2 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx @@ -1,3 +1,14 @@ import { InstlRegistPage } from "@/pages/instl-sncn"; -export default InstlRegistPage; +interface InstlSncnRegistRouteProps { + searchParams: Promise<{ taskSeComCd?: string }>; +} + +export default async function Page({ searchParams }: InstlSncnRegistRouteProps) { + const params = await searchParams; + const taskSeComCd = params.taskSeComCd + ? params.taskSeComCd.split(",").filter(Boolean) + : undefined; + + return ; +} diff --git a/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts index 6110d7be7..777229093 100644 --- a/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts +++ b/apps/as/src/pages/instl-sncn/lib/form-data-mapper.ts @@ -89,14 +89,17 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): CnfmRegistFo }; } -export function getCreateDefaultValues(userInfo: IUserInfo | null): CnfmRegistFormData { +export function getCreateDefaultValues( + userInfo: IUserInfo | null, + taskSeComCd?: string[], +): CnfmRegistFormData { return { instlSncnMng: { bizNm: "", bizBgngYr: "", bizEndYr: "", bizCn: "", - taskSeComCd: [], + taskSeComCd: taskSeComCd ?? [], sncnSeComCd: "", dsgnrNm: "", dsgnrTelno: "", diff --git a/apps/as/src/pages/instl-sncn/ui/components/task-se-picker-dialog.tsx b/apps/as/src/pages/instl-sncn/ui/components/task-se-picker-dialog.tsx new file mode 100644 index 000000000..b1a4fd135 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/ui/components/task-se-picker-dialog.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Field, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field"; +import { useCodeList } from "@/entities/code"; +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Skeleton, + Table, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +interface TaskSePickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (selectedCodes: string[]) => void; +} + +function TaskSePickerDialog({ open, onOpenChange, onConfirm }: TaskSePickerDialogProps) { + const [selectedCodes, setSelectedCodes] = useState([]); + const { data: taskOptions, isPending } = useCodeList("INSTL"); + + useEffect(() => { + if (!open) return; + setSelectedCodes([]); + }, [open]); + + function handleCheckedChange(code: string, checked: boolean) { + setSelectedCodes((prev) => + checked ? [...prev, code] : prev.filter((value) => value !== code), + ); + } + + function handleConfirm() { + onConfirm(selectedCodes); + } + + return ( + + + + 설치인가 신청 + + + {isPending ? ( + + ) : ( + + + + 업무구분 + +
+ + {taskOptions?.map((option) => ( + + + handleCheckedChange(option.lwpoCd, checked === true) + } + /> + + {option.lwpoCdNm} + + + ))} + +
+
+
+
+
+ )} + + + + + +
+
+ ); +} + +export { TaskSePickerDialog }; diff --git a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx index 3e06ca069..9973648fa 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-form-page.tsx @@ -28,9 +28,10 @@ import { PrivacyConsentSection } from "./sections/privacy-consent-section"; interface InstlFormPageProps { mode: "create" | "edit"; initialData?: InstlSncnMngDetResponse; + taskSeComCd?: string[]; } -function InstlFormPage({ mode, initialData }: InstlFormPageProps) { +function InstlFormPage({ mode, initialData, taskSeComCd }: InstlFormPageProps) { const router = useRouter(); const userInfo = useUserStore((state) => state.userInfo); const alert = useAlertStore((state) => state.setMessage); @@ -43,7 +44,7 @@ function InstlFormPage({ mode, initialData }: InstlFormPageProps) { defaultValues: isEditMode && initialData ? mapDetailToFormData(initialData) - : getCreateDefaultValues(userInfo), + : getCreateDefaultValues(userInfo, taskSeComCd), }); async function onSave(data: CnfmRegistFormData) { diff --git a/apps/as/src/pages/instl-sncn/ui/instl-list.tsx b/apps/as/src/pages/instl-sncn/ui/instl-list.tsx index 4f3660dcc..fe202f45a 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-list.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; @@ -11,6 +11,8 @@ import { Button } from "@common/components"; import { createInstlSncnColumns } from "../model/columns"; +import { TaskSePickerDialog } from "./components/task-se-picker-dialog"; + interface InstlListProps { searchParams: InstlSncnMngSearchParams; pagination: PaginationState; @@ -19,6 +21,7 @@ interface InstlListProps { function InstlList({ searchParams, pagination, onPaginationChange }: InstlListProps) { const router = useRouter(); + const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false); const queryParams = useMemo( () => ({ @@ -55,19 +58,32 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr }); const handleRegisterClick = () => { - router.push("/instl-sncn/regist"); + setIsTaskSeDialogOpen(true); + }; + + const handleConfirmTaskSe = (selectedCodes: string[]) => { + setIsTaskSeDialogOpen(false); + const query = selectedCodes.join(","); + router.push(`/instl-sncn/regist?taskSeComCd=${query}`); }; return ( -
-
-

설치인가 목록

- -
- {tableComponent} -
+ <> +
+
+

설치인가 목록

+ +
+ {tableComponent} +
+ + ); } diff --git a/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx index ba4044e8a..0b9472a15 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-regist-page.tsx @@ -1,7 +1,11 @@ import { InstlFormPage } from "./instl-form-page"; -function InstlRegistPage() { - return ; +interface InstlRegistPageProps { + taskSeComCd?: string[]; +} + +function InstlRegistPage({ taskSeComCd }: InstlRegistPageProps) { + return ; } export { InstlRegistPage }; diff --git a/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx index 2a9a7f276..585835571 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/basic-info-form-section.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field"; -import { useCodeList } from "@/entities/code"; +import { findCodeLabel, useCodeList } from "@/entities/code"; import { Button, Checkbox, @@ -41,6 +41,12 @@ function BasicInfoFormSection() { const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE"); const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE"); + const taskSeComCdLabel = + taskSeComCd + .map((code) => findCodeLabel(taskOptions, code)) + .filter(Boolean) + .join(", ") || "-"; + return ( @@ -139,36 +145,11 @@ function BasicInfoFormSection() { ( -
- - {taskOptions?.map((option) => ( - - { - const newValue = checked - ? [...field.value, option.lwpoCd] - : field.value.filter((value) => value !== option.lwpoCd); - field.onChange(newValue); - }} - /> - - {option.lwpoCdNm} - - - ))} - + render={({ fieldState }) => ( + + {taskSeComCdLabel} -
+ )} /> From 0533d229e67cad4d4e3973df144c9e81b560270a Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 29 Jun 2026 17:59:18 +0900 Subject: [PATCH 05/50] =?UTF-8?q?[as]=20feat:=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=EC=9D=B8=EA=B0=80=20=EA=B2=80=ED=86=A0=EC=9D=98=EA=B2=AC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=ED=8F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/instl-sncn/lib/checklist-utils.ts | 115 -------- .../instl-sncn/lib/review-opinion-utils.ts | 37 +++ .../pages/instl-sncn/lib/review-step-utils.ts | 43 +++ .../instl-sncn/lib/save-payload-utils.ts | 104 +++++++ .../ui/components/floating-action-buttons.tsx | 49 +++- .../pages/instl-sncn/ui/instl-view-page.tsx | 22 +- .../ui/sections/checklist-section.tsx | 2 +- .../sections/tech-review-opinion-section.tsx | 263 +++++++++++++++++- 8 files changed, 498 insertions(+), 137 deletions(-) create mode 100644 apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts create mode 100644 apps/as/src/pages/instl-sncn/lib/review-step-utils.ts create mode 100644 apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts diff --git a/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts index adeb89308..cb31e7297 100644 --- a/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts +++ b/apps/as/src/pages/instl-sncn/lib/checklist-utils.ts @@ -1,15 +1,6 @@ import type { - BizCstTreeDto, - BizSclTreeDto, CklstRvwRsltTreeDto, - EsntlAtchFileMngTreeResponseDto, - InstlSncnApplySaveRequestDto, - InstlSncnBizCstSaveRequestDto, - InstlSncnBzScaleSaveDto, InstlSncnCklstResultSaveDto, - InstlSncnEsntlAtchFileSaveDto, - InstlSncnMngDetResponse, - InstlSncnMngResponseDto, ReviewStepCode, } from "@/entities/instl-sncn"; @@ -18,10 +9,7 @@ import { type ChecklistProgress, type ChecklistRowKey, type ChecklistStepMap, - INSTL_PROGRESS_ORDER, REVIEW_STEP_CODES, - REVIEW_STEP_ROLE_RANK, - ROLE_TO_REVIEW_STEP, } from "../model/view-types"; export function buildChecklistRowKey( @@ -41,26 +29,6 @@ export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap return base; } -export function getProgressStepCount(prgrsSttsComCd?: string): number { - const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]); - if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1; - if (idx <= INSTL_PROGRESS_ORDER.indexOf("LE")) return 2; - return 3; -} - -export function getRoleStepCount(dtlInstCd?: string): number { - const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; - return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; -} - -export function getVisibleReviewSteps( - prgrsSttsComCd: string, - userRoleCode?: string, -): ReviewStepCode[] { - const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode)); - return REVIEW_STEP_CODES.slice(0, count); -} - export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] { return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean))); } @@ -211,86 +179,3 @@ export function toCklstSaveDto( }; }); } - -export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] { - return bizSclList - .filter((node) => node.useYn === "Y") - .map((node) => ({ - cnstpSeCd: node.cnstpSeCd, - bscPl: node.bscPlanQty, - budgetReflct: node.bgtRfltQty, - thtiDsgn: node.tstmDesignQty, - diff: node.tstmDesignQty - node.bgtRfltQty, - })); -} - -export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] { - return bizCstList - .filter((node) => node.useYn === "Y") - .map((node) => ({ - acntgSeCd: node.acntgSeCd, - bscPlanAmt: node.bscPlanAmt, - bgtRfltAmt: node.bgtRfltAmt, - tstmDesignAmt: node.tstmDesignAmt, - })); -} - -export function toEsntlAtchFileSaveDto( - esntlFileList: EsntlAtchFileMngTreeResponseDto[], -): InstlSncnEsntlAtchFileSaveDto[] { - const esntlMap = new Map(); - - for (const item of esntlFileList) { - if (!item.esntlAtchFileId || !item.atflCd) continue; - esntlMap.set(item.esntlAtchFileId, item.atflCd); - } - - return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({ - esntlAtchFileId, - atchFileGroupId, - })); -} - -export function buildSavePayload( - data: InstlSncnMngDetResponse, - checklistFormState: ChecklistFormState, - reviewStep: ReviewStepCode, - prgrsSttsComCd: string, -): InstlSncnApplySaveRequestDto { - const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data; - - return { - instlSncnMng: { ...detDto, prgrsSttsComCd }, - bzScaleList: toBzScaleSaveDto(bizSclList), - bizCstList: toBizCstSaveDto(bizCstList), - cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep), - esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList), - }; -} - -export function buildTechReviewReplyPayload( - data: InstlSncnMngDetResponse, - checklistFormState: ChecklistFormState, - reviewStep: ReviewStepCode, - prgrsSttsComCd: string, - techReViewSaveCd: string, -): InstlSncnApplySaveRequestDto { - return { - ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd), - techReViewSaveCd, - rvwStpCd: reviewStep, - }; -} - -export function buildTechReviewPayload( - det: InstlSncnMngResponseDto, - cklstResultList: InstlSncnCklstResultSaveDto[], -): InstlSncnApplySaveRequestDto { - return { - instlSncnMng: { ...det, prgrsSttsComCd: "ER" }, - bzScaleList: [], - bizCstList: [], - cklstResultList, - esntlAtchFileList: [], - }; -} diff --git a/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts b/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts new file mode 100644 index 000000000..56e34a880 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/lib/review-opinion-utils.ts @@ -0,0 +1,37 @@ +import type { + InstlSncnMngDetResponse, + InstlSncnRvwOpnnSaveRequestDto, +} from "@/entities/instl-sncn"; + +export interface ReviewOpinionFormData { + rvwRsltCn: string; + rvwRsltAnmlyYn: "Y" | "N" | ""; + atchFileGroupId: string; + opnrSeComCd: string; +} + +export function toRvwOpnnSaveDto( + data: InstlSncnMngDetResponse, + reviewOpinionFormData: ReviewOpinionFormData | null | undefined, + opnrNm?: string, +): InstlSncnRvwOpnnSaveRequestDto[] | undefined { + if (!reviewOpinionFormData) return undefined; + + const hasContent = + reviewOpinionFormData.rvwRsltCn.trim() || + reviewOpinionFormData.rvwRsltAnmlyYn || + reviewOpinionFormData.atchFileGroupId; + + if (!hasContent) return undefined; + + return [ + { + instlSncnDocNo: data.detDto.instlSncnDocNo, + opnrSeComCd: reviewOpinionFormData.opnrSeComCd, + opnrNm: opnrNm ?? "", + rvwRsltCn: reviewOpinionFormData.rvwRsltCn, + rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined, + atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined, + }, + ]; +} diff --git a/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts b/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts new file mode 100644 index 000000000..68f60f4c9 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/lib/review-step-utils.ts @@ -0,0 +1,43 @@ +import type { ReviewStepCode } from "@/entities/instl-sncn"; + +import { + INSTL_PROGRESS_ORDER, + REVIEW_STEP_CODES, + REVIEW_STEP_ROLE_RANK, + ROLE_TO_REVIEW_STEP, +} from "../model/view-types"; + +export function getProgressStepCount(prgrsSttsComCd?: string): number { + const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]); + if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1; + if (idx <= INSTL_PROGRESS_ORDER.indexOf("LE")) return 2; + return 3; +} + +export function getRoleStepCount(dtlInstCd?: string): number { + const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined; + return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; +} + +export function getVisibleReviewSteps( + prgrsSttsComCd: string, + userRoleCode?: string, +): ReviewStepCode[] { + const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode)); + return REVIEW_STEP_CODES.slice(0, count); +} + +export function getEditableReviewStep( + dtlInstCd?: string, + prgrsSttsComCd?: string, +): ReviewStepCode | undefined { + if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) { + return "ENV"; + } + + if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") { + return "KECO"; + } + + return undefined; +} diff --git a/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts new file mode 100644 index 000000000..adea42a99 --- /dev/null +++ b/apps/as/src/pages/instl-sncn/lib/save-payload-utils.ts @@ -0,0 +1,104 @@ +import type { + BizCstTreeDto, + BizSclTreeDto, + EsntlAtchFileMngTreeResponseDto, + InstlSncnApplySaveRequestDto, + InstlSncnBizCstSaveRequestDto, + InstlSncnBzScaleSaveDto, + InstlSncnCklstResultSaveDto, + InstlSncnEsntlAtchFileSaveDto, + InstlSncnMngDetResponse, + InstlSncnMngResponseDto, + ReviewStepCode, +} from "@/entities/instl-sncn"; + +import type { ChecklistFormState } from "../model/view-types"; + +import { toCklstSaveDto } from "./checklist-utils"; +import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils"; + +export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] { + return bizSclList + .filter((node) => node.useYn === "Y") + .map((node) => ({ + cnstpSeCd: node.cnstpSeCd, + bscPl: node.bscPlanQty, + budgetReflct: node.bgtRfltQty, + thtiDsgn: node.tstmDesignQty, + diff: node.tstmDesignQty - node.bgtRfltQty, + })); +} + +export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] { + return bizCstList + .filter((node) => node.useYn === "Y") + .map((node) => ({ + acntgSeCd: node.acntgSeCd, + bscPlanAmt: node.bscPlanAmt, + bgtRfltAmt: node.bgtRfltAmt, + tstmDesignAmt: node.tstmDesignAmt, + })); +} + +export function toEsntlAtchFileSaveDto( + esntlFileList: EsntlAtchFileMngTreeResponseDto[], +): InstlSncnEsntlAtchFileSaveDto[] { + const esntlMap = new Map(); + + for (const item of esntlFileList) { + if (!item.esntlAtchFileId || !item.atflCd) continue; + esntlMap.set(item.esntlAtchFileId, item.atflCd); + } + + return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({ + esntlAtchFileId, + atchFileGroupId, + })); +} + +export function buildSavePayload( + data: InstlSncnMngDetResponse, + checklistFormState: ChecklistFormState, + reviewStep: ReviewStepCode, + prgrsSttsComCd: string, +): InstlSncnApplySaveRequestDto { + const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data; + + return { + instlSncnMng: { ...detDto, prgrsSttsComCd }, + bzScaleList: toBzScaleSaveDto(bizSclList), + bizCstList: toBizCstSaveDto(bizCstList), + cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep), + esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList), + }; +} + +export function buildTechReviewReplyPayload( + data: InstlSncnMngDetResponse, + checklistFormState: ChecklistFormState, + reviewStep: ReviewStepCode, + prgrsSttsComCd: string, + techReViewSaveCd: string, + reviewOpinionFormData?: ReviewOpinionFormData | null, + opnrNm?: string, +): InstlSncnApplySaveRequestDto { + return { + ...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd), + techReViewSaveCd, + rvwStpCd: reviewStep, + rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm), + }; +} + +export function buildTechReviewPayload( + det: InstlSncnMngResponseDto, + cklstResultList: InstlSncnCklstResultSaveDto[], +): InstlSncnApplySaveRequestDto { + return { + instlSncnMng: { ...det, prgrsSttsComCd: "ER" }, + bzScaleList: [], + bizCstList: [], + cklstResultList, + esntlAtchFileList: [], + }; +} diff --git a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx index 1bbafa208..f9bbd2834 100644 --- a/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx +++ b/apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx @@ -25,12 +25,10 @@ import { type InstlSncnViewButtonAction, needsRoleUser01AtrzResolve, } from "../../lib/button-config"; -import { - buildSavePayload, - buildTechReviewReplyPayload, - computeReviewStepChecklistProgress, -} from "../../lib/checklist-utils"; +import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils"; +import { buildSavePayload, buildTechReviewReplyPayload } from "../../lib/save-payload-utils"; import { type ChecklistFormState } from "../../model/view-types"; +import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section"; import { ElaprApprovalDialog } from "./elapr-approval-dialog"; import { ElaprTmpltPickerDialog } from "./elapr-tmplt-picker-dialog"; @@ -43,6 +41,8 @@ interface FloatingActionButtonsProps { prgrsSttsComCd: string; dtlInstCd: string | undefined; checklistFormState: ChecklistFormState; + techReviewOpinionRef?: React.RefObject; + opnrNm?: string; } type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null; @@ -54,6 +54,8 @@ function FloatingActionButtons({ prgrsSttsComCd, dtlInstCd, checklistFormState, + techReviewOpinionRef, + opnrNm, }: FloatingActionButtonsProps) { const router = useRouter(); const { confirm } = useConfirm(); @@ -251,6 +253,10 @@ function FloatingActionButtons({ }); } + function getReviewOpinionFormData() { + return techReviewOpinionRef?.current?.getFormData() ?? null; + } + async function handleRvwRplyClick() { const { percent } = computeReviewStepChecklistProgress( data.cklstResultList ?? [], @@ -263,13 +269,22 @@ function FloatingActionButtons({ return; } - const payload = buildTechReviewReplyPayload(data, checklistFormState, "ENV", "EP", "CP"); + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "ENV", + "EP", + "CP", + getReviewOpinionFormData(), + opnrNm, + ); await executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, request: payload, successMessage: "검토결과 회신이 완료되었습니다.", }); + techReviewOpinionRef?.current?.resetForm(); } async function handleRqstTechRvwClick() { @@ -303,13 +318,22 @@ function FloatingActionButtons({ } async function handleRvwStrgClick() { - const payload = buildTechReviewReplyPayload(data, checklistFormState, "KECO", "TE", "SV"); + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "KECO", + "TE", + "SV", + getReviewOpinionFormData(), + opnrNm, + ); await executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg, request: payload, successMessage: "저장되었습니다.", }); + techReviewOpinionRef?.current?.resetForm(); } async function handleRvwCmptnClick() { @@ -324,13 +348,22 @@ function FloatingActionButtons({ return; } - const payload = buildTechReviewReplyPayload(data, checklistFormState, "KECO", "TE", "CP"); + const payload = buildTechReviewReplyPayload( + data, + checklistFormState, + "KECO", + "TE", + "CP", + getReviewOpinionFormData(), + opnrNm, + ); await executeAction({ action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn, request: payload, successMessage: "검토완료 처리되었습니다.", }); + techReviewOpinionRef?.current?.resetForm(); } function handleDmndSplmntClick() { diff --git a/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx index fb1bf5fa1..12ae43453 100644 --- a/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/instl-view-page.tsx @@ -1,9 +1,12 @@ "use client"; +import { useRef } from "react"; + import { useUserStore } from "@/shared/lib"; import { useInstlSncnDetail } from "@/entities/instl-sncn"; import { Skeleton } from "@common/components"; +import { getEditableReviewStep } from "../lib/review-step-utils"; import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state"; import { FloatingActionButtons } from "./components/floating-action-buttons"; @@ -13,7 +16,10 @@ import { BusinessCostSection } from "./sections/business-cost-section"; import { BusinessScaleSection } from "./sections/business-scale-section"; import { ChecklistSection } from "./sections/checklist-section"; import { ContactInfoSection } from "./sections/contact-info-section"; -import { TechReviewOpinionSection } from "./sections/tech-review-opinion-section"; +import { + TechReviewOpinionSection, + type TechReviewOpinionSectionHandle, +} from "./sections/tech-review-opinion-section"; interface InstlViewPageProps { id: string; @@ -21,6 +27,7 @@ interface InstlViewPageProps { function InstlViewPage({ id }: InstlViewPageProps) { const userInfo = useUserStore((state) => state.userInfo); + const techReviewOpinionRef = useRef(null); const { data, isLoading, isError } = useInstlSncnDetail(id); const { formState: checklistFormState, setFormState: setChecklistFormState } = useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST); @@ -58,6 +65,11 @@ function InstlViewPage({ id }: InstlViewPageProps) { rvwList = [], } = data; + const editableReviewStep = getEditableReviewStep( + userInfo?.dtlInstCd, + detDto.prgrsSttsComCd || "", + ); + return (
@@ -72,7 +84,11 @@ function InstlViewPage({ id }: InstlViewPageProps) { setFormState={setChecklistFormState} /> - +
); diff --git a/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx index 2f7e8dd74..b7dab7962 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/checklist-section.tsx @@ -9,8 +9,8 @@ import { computeReviewStepChecklistProgress, extractTaskCodes, getChecklistDisplayRows, - getVisibleReviewSteps, } from "../../lib/checklist-utils"; +import { getVisibleReviewSteps } from "../../lib/review-step-utils"; import { type ChecklistFormState, REVIEW_STEP_LABELS, diff --git a/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx b/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx index 69dc7ae59..e7b80386e 100644 --- a/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx +++ b/apps/as/src/pages/instl-sncn/ui/sections/tech-review-opinion-section.tsx @@ -1,44 +1,285 @@ "use client"; -import { PROXY_HOST } from "@/shared/config"; -import { dayjs } from "@/shared/lib"; -import type { InstlSncnRvwOpnnResponseDto } from "@/entities/instl-sncn"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; +import { PROXY_HOST } from "@/shared/config"; +import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; +import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; +import { attachService } from "@/entities/attach"; +import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn"; +import { + Button, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils"; import { REVIEW_STEP_LABELS } from "../../model/view-types"; import { SectionCollapsible } from "../components/section-collapsible"; +const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; + interface TechReviewOpinionSectionProps { rvwList: InstlSncnRvwOpnnResponseDto[]; + editableReviewStep?: ReviewStepCode; +} + +export interface TechReviewOpinionSectionHandle { + getFormData: () => ReviewOpinionFormData | null; + resetForm: () => void; } interface TechReviewOpinionTableProps { items: InstlSncnRvwOpnnResponseDto[]; } -function TechReviewOpinionSection({ rvwList }: TechReviewOpinionSectionProps) { +interface UploadedOpinionFile { + fileName: string; + fileKey: string; + atflId?: string; + atflCd?: string; +} + +const EMPTY_FORM_DATA: ReviewOpinionFormData = { + rvwRsltCn: "", + rvwRsltAnmlyYn: "", + atchFileGroupId: "", + opnrSeComCd: "", +}; + +function createRvwOpnnUploadKey(file: File) { + return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; +} + +function hasFormContent(formData: ReviewOpinionFormData) { + return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); +} + +const TechReviewOpinionSection = forwardRef< + TechReviewOpinionSectionHandle, + TechReviewOpinionSectionProps +>(function TechReviewOpinionSection({ rvwList, editableReviewStep }, ref) { + const [formData, setFormData] = useState({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + const [uploadedFile, setUploadedFile] = useState(null); + + useEffect(() => { + setFormData((prev) => ({ + ...prev, + opnrSeComCd: editableReviewStep ?? "", + })); + }, [editableReviewStep]); + + const { uploadFileAsync, isUploading, uploads } = useTus({ + onSuccess: (atflCd, atflId, _file, fileKey) => { + if (!atflCd || !atflId) return; + + setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); + setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev)); + }, + onError: (_error, _file, fileKey) => { + setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); + }, + }); + + useImperativeHandle( + ref, + () => ({ + getFormData: () => { + if (!editableReviewStep || !hasFormContent(formData)) { + return null; + } + + return { + ...formData, + opnrSeComCd: editableReviewStep, + }; + }, + resetForm: () => { + setFormData({ + ...EMPTY_FORM_DATA, + opnrSeComCd: editableReviewStep ?? "", + }); + setUploadedFile(null); + }, + }), + [editableReviewStep, formData], + ); + const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); - if (envRvwList.length === 0 && kecoRvwList.length === 0) { + const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV"; + const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO"; + + if (!hasEnvSection && !hasKecoSection) { return null; } + async function handleFileUpload(files: FileList | null) { + if (!files || files.length === 0) return; + + const file = files[0]; + const fileKey = createRvwOpnnUploadKey(file); + const existingAtflCd = formData.atchFileGroupId; + + setUploadedFile({ fileName: file.name, fileKey }); + + try { + await uploadFileAsync(file, { + fileKey, + metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, + }); + } catch { + // onError callback handles cleanup + } + } + + async function handleFileRemove() { + if (!uploadedFile) return; + + uploads[uploadedFile.fileKey]?.abort(); + + if (uploadedFile.atflId) { + try { + await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); + } catch (error) { + console.error("검토의견 첨부파일 삭제 실패:", error); + } + } + + setUploadedFile(null); + setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); + } + + function renderOpinionForm(reviewStep: ReviewStepCode) { + if (editableReviewStep !== reviewStep) return null; + + const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; + + return ( +
+

검토의견 작성

+
+ + + 의견내용 + +