From 06f5a3cde0bac960e210fe87ffb9c2e01fe2d6de Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 30 Jun 2026 18:14:19 +0900 Subject: [PATCH] =?UTF-8?q?wr:=20=ED=95=98=EC=88=98=EC=9E=AC=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=EC=8B=A4=EC=A0=81=20=EB=93=B1=EB=A1=9D1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/wr/.env.local | 4 +- .../prfmncReg/pbnstPrfmncReg/page.tsx | 430 +++++++++--------- .../store/prfmncReg/pbnstPrfmncReg/index.tsx | 212 +++------ .../store/prfmncReg/pbnstPrfmncReg/type.tsx | 18 + .../pbnPrfExcelModal/pbnPrfExcelModal.tsx | 82 ++-- .../하수재이용실적 등록 api 호출분석_20260630.md | 245 ++++++++++ .../하수처리수재이용 실적등록_20260630.md | 285 ++++++++++++ apps/wr/src/app/styles/components-black.css | 7 +- .../src/ui/chart/BarChart.tsx | 12 +- .../src/ui/chart/DoubleBar.tsx | 12 +- .../src/ui/chart/DoughnutChart.tsx | 10 +- .../src/ui/chart/LineBarChart.tsx | 28 +- .../src/ui/chart/LineBarChart2.tsx | 14 +- .../src/ui/chart/LineChart.tsx | 12 +- 14 files changed, 953 insertions(+), 418 deletions(-) create mode 100644 apps/wr/docs/기준문서/하수재이용실적 등록 api 호출분석_20260630.md create mode 100644 apps/wr/docs/작업계획/하수처리수재이용 실적등록_20260630.md diff --git a/apps/wr/.env.local b/apps/wr/.env.local index 96b66b536..36faf6c47 100644 --- a/apps/wr/.env.local +++ b/apps/wr/.env.local @@ -13,8 +13,8 @@ NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver -NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 -# NEXT_PUBLIC_WR_SERVER_API_URL = http://localhost:8883/reuse-service/api/v1 +# NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 +NEXT_PUBLIC_WR_SERVER_API_URL = http://localhost:8883/reuse-service/api/v1 NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API # NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883/sewerage-service NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr diff --git a/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx b/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx index a2013102e..d815f2f4d 100644 --- a/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx +++ b/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx @@ -1,4 +1,4 @@ -/** +/** * 재이용실적등록 > 하수재이용실적등록 */ "use client"; @@ -10,13 +10,11 @@ import { initValOutUsage, subTotalObj, totalObj, - type FcPrfType, + type PbnstPrfRegDtlType, + type PbnstPrfRegListType, type PrfType, } from "@/app/store/prfmncReg/pbnstPrfmncReg/type"; -import { - excelDownload, - yearOptions, -} from "@/components/lib/func/common"; +import { excelDownload, yearOptions } from "@/components/lib/func/common"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { pbnstTableBody } from "@/components/lib/func/TableData"; import PbnPrfExcelModal from "@/components/ui/modal/chkgd/pbnPrfExcelModal/pbnPrfExcelModal"; @@ -32,18 +30,104 @@ import { UiTable, } from "@common/business_components/ui"; import ListDiv from "@common/business_components/ui/list_div"; -import { - Button, - Label, - Table, - TableBody, - TableHeader, -} from "@common/components/ui"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Label, Table, TableBody, TableHeader } from "@common/components/ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAlertStore } from "@/src/shared/lib"; import { useTranslation } from "react-i18next"; import { headMakeprfmnc } from "../chkgdnReg/TableHead"; +type PbnstPrfmncRow = { + sido: string; + gugun: string; + bscFctNm: string; + ruseUtztnFcltNm: string; + pbnstRuseFcltId: string; +}; + +const buildInputData = (detail: PbnstPrfRegDtlType | null, searchYear: string) => { + if (!detail?.prfList?.length) { + return []; + } + + const inUsageData = structuredClone(initValInUsage).map((row) => ({ + ...row, + 구분: "장내용수", + })); + const outUsageData = structuredClone(initValOutUsage).map((row) => ({ + ...row, + 구분: "장외용수", + })); + const inSubtotal = { + ...structuredClone(subTotalObj), + 구분: "장내용수", + }; + const outSubtotal = { + ...structuredClone(subTotalObj), + 구분: "장외용수", + }; + + detail.prfList.forEach((prfItem) => { + const yr = Number(prfItem.ym?.slice(0, 4)); + if (yr !== Number(searchYear)) { + return; + } + + const month = Number(prfItem?.ym?.slice(4)); + const monthKey = `${month}월`; + + inUsageData.forEach((inData: any) => { + const usageKey = inData.usage; + const value = (prfItem as any)[usageKey] ?? 0; + + inData[monthKey] = value; + inData.total += value; + + if (usageKey !== "subtotal") { + (inSubtotal as any)[monthKey] = + ((inSubtotal as any)[monthKey] ?? 0) + value; + inSubtotal.total += value; + } + }); + + outUsageData.forEach((outData: any) => { + const usageKey = outData.usage; + const value = (prfItem as any)[usageKey] ?? 0; + + outData[monthKey] = value; + outData.total += value; + + if (usageKey !== "subtotal") { + (outSubtotal as any)[monthKey] = + ((outSubtotal as any)[monthKey] ?? 0) + value; + outSubtotal.total += value; + } + }); + }); + + if (inSubtotal.total === 0 && outSubtotal.total === 0) { + return []; + } + + inUsageData.push(inSubtotal); + outUsageData.push(outSubtotal); + + const usageTotalObj: { [key: string]: any } = { + ...structuredClone(totalObj), + 구분: "총합계", + 용수명: "", + }; + for (const key in usageTotalObj) { + if (["usage", "gubun", "edit"].includes(key)) continue; + const val = + (Number((inSubtotal as any)[key]) || 0) + + (Number((outSubtotal as any)[key]) || 0); + + (usageTotalObj as any)[key] = val; + } + + return [...inUsageData, ...outUsageData, usageTotalObj]; +}; + export default function PrfmncPrfmncReg() { const { auth, @@ -51,59 +135,85 @@ export default function PrfmncPrfmncReg() { prfmncHasuInit, prfmnRegData, prfmncHasuNmSearch, - getPrfFmcRes, + getPbnstPrfRegDtl, getLaeSeCdCheck, + returnCodeText, } = useWrStore((state) => state); - const { - prfPbnFclts, - pbnstPrfmncHeadList, - monthHeadList, - prfmncRes, - prfmncResCount, - } = prfmnc; + const { pbnstPrfmncHeadList, monthHeadList, prfmncRes, prfmncResCount, prfmncDetail } = + prfmnc; const { regAuthrtYn, mdfcnAuthrtYn, laeSeCd } = auth; const [isReset, setIsReset] = useState(false); - const [bdstTable, setbdstTable] = useState(); - const [searchTxt, setSearchTxt] = useState(); + const [searchTxt, setSearchTxt] = useState(""); + const [selectedFacilityId, setSelectedFacilityId] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [inputData, setInputData] = useState([]); const [isExcelOpen, setIsExcelOPen] = useState<{ isModal: boolean; - data: FcPrfType; - }>({ isModal: false, data: {} as FcPrfType }); - const [inputData, setInputData] = useState(); - const [pbnstRuseFcltId, setpbnstRuseFcltId] = useState(); - const [cellNum, setCellNum] = useState(1); - const [searchYear, setSearchYear] = useState( - String(new Date().getFullYear()) - ); + data: PbnstPrfRegDtlType; + }>({ isModal: false, data: {} as PbnstPrfRegDtlType }); + const [searchYear, setSearchYear] = useState(String(new Date().getFullYear())); const { confirm } = useConfirm(); const alert = useAlertStore((state) => state.setMessage); + const { t } = useTranslation("base"); - const useInitRef = useRef(false); + const bdstTable = useMemo( + () => + prfmncRes.map((item: PbnstPrfRegListType) => ({ + sido: returnCodeText(item.sidoCd, "cityCd"), + gugun: + item.sggCd && String(item.sggCd).trim().length === 5 + ? returnCodeText(String(item.sggCd), "sggCd") + : "", + bscFctNm: item.swageBscFctNm, + ruseUtztnFcltNm: item.ruseUtztnFcltNm, + pbnstRuseFcltId: item.pbnstRuseFcltId, + })), + [prfmncRes, returnCodeText] + ); + + const derivedInputData = useMemo( + () => buildInputData(prfmncDetail, searchYear), + [prfmncDetail, searchYear] + ); const excelClick = useCallback(() => { - excelDownload( - [[]], - "하수재이용실적", - "sample_pbnstPrfmncReg.xlsx", - "A1:O1" - ); - }, [prfmnc.excelList]); + excelDownload([[]], "하수재이용실적", "sample_pbnstPrfmncReg.xlsx", "A1:O1"); + }, []); + + const loadDetail = useCallback( + async (facilityId: string) => { + if (!facilityId) return; + + const detail = await getPbnstPrfRegDtl(searchYear, facilityId); + if (detail) { + setSelectedFacilityId(facilityId); + setIsExcelOPen((prev) => ({ + ...prev, + data: detail, + })); + } + }, + [getPbnstPrfRegDtl, searchYear] + ); + + const handleSearch = useCallback(() => { + if (!laeSeCd) return; + + setIsReset(true); + }, [laeSeCd]); - const { t } = useTranslation("base"); const onSave = useCallback(async () => { const handleConfirm = async () => { - if (!pbnstRuseFcltId) return; + if (!selectedFacilityId) return; const message = await prfmnRegData( - 1, - laeSeCd, excelToPbnPrfData( inputData as unknown as JSON[], searchYear, - pbnstRuseFcltId - ) as unknown as PrfType, + selectedFacilityId + ) as unknown as PrfType[], searchYear, - prfPbnFclts + selectedFacilityId ); alert(message); }; @@ -111,126 +221,47 @@ export default function PrfmncPrfmncReg() { if (await confirm(t("isUpdateMsg"))) { await handleConfirm(); } - }, [inputData, searchYear, pbnstRuseFcltId, cellNum, prfPbnFclts, confirm, t, laeSeCd, prfmnRegData]); - - const cellClick = useCallback( - (index: number | undefined) => { - if (index !== undefined) { - setIsExcelOPen({ - isModal: false, - data: prfmncRes[index] || ({} as FcPrfType), - }); - const inUsageData = structuredClone(initValInUsage).map((row) => ({ - ...row, - 구분: "장내용수", - })); - const outUsageData = structuredClone(initValOutUsage).map((row) => ({ - ...row, - 구분: "장외용수", - })); - const inSubtotal = { - ...structuredClone(subTotalObj), - 구분: "장내용수", - }; - const outSubtotal = { - ...structuredClone(subTotalObj), - 구분: "장외용수", - }; - if (prfmncRes[index]?.prfList) { - prfmncRes[index].prfList?.forEach((prfItem) => { - const yr = Number(prfItem.ym?.slice(0, 4)); // "YYYY" - - if (yr === Number(searchYear)) { - const month = Number(prfItem?.ym?.slice(4)); // "MM" - const monthKey = `${month}월`; - - inUsageData.forEach((inData: any) => { - const usageKey = inData.usage; - const value = (prfItem as any)[usageKey] ?? 0; - - inData[monthKey] = value; - inData.total += value; - - if (inSubtotal && usageKey !== "subtotal") { - (inSubtotal as any)[monthKey] = - ((inSubtotal as any)[monthKey] ?? 0) + value; - inSubtotal.total += value; - } - }); - - outUsageData.forEach((outData: any) => { - const usageKey = outData.usage; - const value = (prfItem as any)[usageKey] ?? 0; - - outData[monthKey] = value; - outData.total += value; - - if (usageKey !== "subtotal") { - (outSubtotal as any)[monthKey] = - ((outSubtotal as any)[monthKey] ?? 0) + value; - outSubtotal.total += value; - } - }); - } - }); - - if (inSubtotal.total !== 0 && outSubtotal.total !== 0) { - inUsageData.push(inSubtotal); - outUsageData.push(outSubtotal); - const usageTotalObj: { [key: string]: any } = { - ...structuredClone(totalObj), - 구분: "총합계", - 용수명: "", - }; - for (const key in usageTotalObj) { - if (["usage", "gubun", "edit"].includes(key)) continue; - const val = - (Number((inSubtotal as any)[key]) || 0) + - (Number((outSubtotal as any)[key]) || 0); - - (usageTotalObj as any)[key] = val; - } - const tableData = [...inUsageData, ...outUsageData, usageTotalObj]; - setpbnstRuseFcltId(prfmncRes[index].pbnstRuseFcltId); - setInputData(tableData); - } else { - setInputData([]); - } - } else { - setInputData([]); - } - } - }, - [prfmncRes, searchYear] - ); - useEffect(() => { - getLaeSeCdCheck(); - useInitRef.current = true; - }, []); - useEffect(() => { - if (useInitRef.current == true && laeSeCd !== undefined) { - getPrfFmcRes(); - useInitRef.current = true; - } - }, [useInitRef.current, laeSeCd]); + }, [alert, confirm, inputData, prfmnRegData, searchYear, selectedFacilityId, t]); useEffect(() => { - const year = new Date().getFullYear(); - - if(laeSeCd === undefined || laeSeCd ==='') return; - prfmncHasuInit(1, laeSeCd, String(year), prfPbnFclts); - }, [prfPbnFclts,laeSeCd]); + void getLaeSeCdCheck(); + }, [getLaeSeCdCheck]); useEffect(() => { - if (prfmncRes && prfmncRes.length > 0) { - setbdstTable(prfmncRes); + if (!laeSeCd) return; + void prfmncHasuInit(1, laeSeCd); + }, [laeSeCd, prfmncHasuInit]); + + useEffect(() => { + if (!prfmncRes.length) { + setSelectedFacilityId(""); + setSelectedIndex(0); + setInputData([]); + setIsExcelOPen((prev) => ({ ...prev, data: {} as PbnstPrfRegDtlType })); + return; } - // cellNum이 0일 경우(첫 번째 행)도 유효한 인덱스이므로 undefined 여부만 체크합니다. - if (cellNum !== undefined) { - cellClick(cellNum); + const nextIndex = Math.max( + prfmncRes.findIndex((item) => item.pbnstRuseFcltId === selectedFacilityId), + 0 + ); + const nextFacility = prfmncRes[nextIndex]; + if (!nextFacility) { + return; } - }, [prfmncRes, cellNum, cellClick]); + + setSelectedIndex(nextIndex); + void loadDetail(nextFacility.pbnstRuseFcltId); + }, [loadDetail, prfmncRes]); + + useEffect(() => { + if (!selectedFacilityId) return; + void loadDetail(selectedFacilityId); + }, [loadDetail, searchYear, selectedFacilityId]); + + useEffect(() => { + setInputData(derivedInputData); + }, [derivedInputData]); return ( <> @@ -249,9 +280,7 @@ export default function PrfmncPrfmncReg() { selectClass={""} selectArray={yearOptions} value={searchYear} - onSelectValue={(val: string | number) => - setSearchYear(String(val)) - } + onSelectValue={(val: string | number) => setSearchYear(String(val))} />
@@ -261,7 +290,7 @@ export default function PrfmncPrfmncReg() { { setSearchTxt(e); }} @@ -271,12 +300,7 @@ export default function PrfmncPrfmncReg() {
-
- - - {headMakeprfmnc(monthHeadList, [])} - - - {pbnstTableBody( - inputData ?? [], - monthHeadList, - setInputData, - mdfcnAuthrtYn === "N" ? false : true - )} - -
+
+ + + {headMakeprfmnc(monthHeadList, [])} + + + {pbnstTableBody( + inputData, + monthHeadList, + setInputData, + mdfcnAuthrtYn === "N" ? false : true + )} + +
+
@@ -376,22 +400,20 @@ export default function PrfmncPrfmncReg() { { - setIsExcelOPen({ ...isExcelOpen, isModal: open }); + setIsExcelOPen((prev) => ({ ...prev, isModal: open })); }} setModalExcelData={async (excelData) => { - const message = await prfmnRegData(1, laeSeCd, excelData, searchYear, prfPbnFclts); + if (!selectedFacilityId) return; + const message = await prfmnRegData(excelData, searchYear, selectedFacilityId); alert(message); - // 데이터 저장 후 UI 업데이트는 useEffect가 감지하여 처리하므로 여기서는 호출하지 않습니다. }} > } open={isExcelOpen.isModal} onOpenChange={() => { - setIsExcelOPen({ ...isExcelOpen, isModal: false }); + setIsExcelOPen((prev) => ({ ...prev, isModal: false })); }} > ); } - - diff --git a/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx b/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx index 83057114c..cadba69d4 100644 --- a/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx +++ b/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx @@ -1,229 +1,155 @@ -/** +/** * 재이용실적등록 > 하수재이용실적등록 - * - * 🔗 사용 API - * - 하수재이용시설 실적 조회: POST /api/v1/reuseFc/pubDetailList/{year}/{sggOrCity}/{page}/{pageSize} (reuse-service) - * - 하수재이용시설 실적 등록: POST /api/v1/reuseFc/pubFcPrf (reuse-service) - * - 공공재이용시설 목록 조회: POST /api/v1/prcsFclt/ruseInfo/{page}/{pageSize}/{type}/{sggOrCity}/{etc1}/{etc2}/{etc3} (server-service) - * - 공공재이용시설 목록 조회(이름검색): POST /api/v1/prcsFclt/ruseInfo/{page}/{pageSize}/{type}/{sggOrCity}/{searchTxt} (server-service) - * - 공공재이용시설 조회: GET /api/v1/reuseFc/pubFc (reuse-service) + * + * 🔗 사용 API + * - 좌측 목록 조회: GET /api/v1/reuseFc/pbnstPrfRegList/{page}/{pageSize}/{searchingCd}/{nameCd} + * - 우측 상세 조회: GET /api/v1/reuseFc/pbnstPrfRegDtl/{year}/{pbnstRuseFcltId} + * - 하수재이용시설 실적 등록: POST /api/v1/reuseFc/pubFcPrf **/ -import { fetchType, getFetch, postFectch } from "@/components/lib/api"; +import { fetchType, getFetch, getFetchWithCount, postFectch } from "@/components/lib/api"; import { pbnstPrfmncHeadListExport, monthHeadListExport } from "./headList"; -import type { pubHasuType } from "../../ruseFcltMng/fcltReg/pbnstReg/type"; -import type { FcPrfType, PrfType } from "./type"; +import type { PbnstPrfRegDtlType, PbnstPrfRegListType, PrfType } from "./type"; import type { TableUpperProps } from "../../common/type/header"; const pbnstPrfmncHeadList = pbnstPrfmncHeadListExport; const monthHeadList = monthHeadListExport; export const prfRegPageSize = 10; + export type pbnstPrfmncState = { prfmnc: { isInit: boolean; pbnstPrfmncHeadList: TableUpperProps[]; monthHeadList: TableUpperProps[]; - prfmncRes: FcPrfType[]; + prfmncRes: PbnstPrfRegListType[]; prfmncResCount: number; excelList: number[]; pageKey: number; - prfPbnFclts: string[]; + prfmncDetail: PbnstPrfRegDtlType | null; }; }; export const pbnstPrfmncStateExport: pbnstPrfmncState = { prfmnc: { isInit: false, - pbnstPrfmncHeadList: pbnstPrfmncHeadList, - monthHeadList: monthHeadList, + pbnstPrfmncHeadList, + monthHeadList, prfmncRes: [], - prfPbnFclts: [], prfmncResCount: 1, excelList: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], pageKey: 1, + prfmncDetail: null, }, }; export type pbnstPrfmncType = pbnstPrfmncState & pbnstPrfmncActions; type pbnstPrfmncActions = { - prfmncHasuInit: ( - page: number, - sggOrCity: string, - year: string, - prfPbnFclts: string[] - ) => void; - prfmncHasuNmSearch: ( - page: number, - sggOrCity: string, - year: string, - searchTxt: string, - prfPbnFclts: string[] - ) => void; - prfmnRegData: ( - page: number, - sggOrCity: string, - data: PrfType, - year: string, - prfPbnFclts: string[] - ) => Promise; - getPrfFmcRes: () => void; + prfmncHasuInit: (page: number, sggOrCity: string) => void; + prfmncHasuNmSearch: (page: number, sggOrCity: string, searchTxt: string) => void; + prfmnRegData: (data: PrfType[], year: string, pbnstRuseFcltId: string) => Promise; + getPbnstPrfRegDtl: (year: string, pbnstRuseFcltId: string) => Promise; }; -const hasuListFetch = async ( +const fetchPbnstPrfRegList = async ( page: number, sggOrCity: string, - year: string, - prfPbnFclts: string[], searchTxt?: string ) => { - let filteredFclt = prfPbnFclts; - if (sggOrCity !== "0") { - filteredFclt = prfPbnFclts?.filter((fclt: string) => { - return sggOrCity.length == 2 - ? fclt.slice(0, 2) === sggOrCity - : fclt.slice(0, 5) === sggOrCity; - }); + let url = `/reuseFc/pbnstPrfRegList/${page}/${prfRegPageSize}/A/${sggOrCity}`; + if (searchTxt && searchTxt.trim().length > 0) { + url += `?swtrmtFcltNm=${encodeURIComponent(searchTxt.trim())}`; } - //let url = `/prcsFclt/ruseInfo/1/${filteredFclt.length + 1}/A/${sggOrCity}/0/1/SW`; - let url = `/prcsFclt/ruseInfo/1/${filteredFclt.length + 1}`; - if (searchTxt && searchTxt?.length > 0) { - url += `/${searchTxt}`; - } - const rainRes = await postFectch(url, filteredFclt, fetchType.COMMON); - const checkRuseFcltList = - searchTxt && searchTxt?.length > 0 - ? rainRes.data.map((item) => item.bscFctCd) - : filteredFclt; - - const pubRuseList = await postFectch( - `/reuseFc/pubDetailList/${year}/${sggOrCity}/${page}/${prfRegPageSize}`, - checkRuseFcltList, - fetchType.WR - ); - const rusePrfList = pubRuseList.data - .map((item: FcPrfType) => { - const findData = rainRes.data?.find( - (fclt: pubHasuType) => - item.pbnstRuseFcltId.substring(0, 11) === fclt?.bscFctCd - ); - if (findData !== undefined) { - return { - sido: findData.sido, - gugun: findData.gugun, - bscFctNm: findData.bscFctNm, - bscFctCd: findData.bscFctCd, - ruseUtztnFcltNm: item.ruseUtztnFcltNm, - pbnstRuseFcltId: item.pbnstRuseFcltId, - prfList: item.prfList, - }; - } - }) - .filter(Boolean); // undefined 값을 제거합니다. - - return { data: rusePrfList, count: pubRuseList.count }; + const response = await getFetchWithCount(fetchType.WR, url); + return { + data: (response?.data ?? []) as PbnstPrfRegListType[], + count: response?.count ?? 0, + }; }; + export const pbnstPrfmncActionsExport: (set: any) => pbnstPrfmncActions = ( set: any ) => { return { - getPrfFmcRes: async () => { + prfmncHasuInit: async (page: number, sggOrCity: string) => { try { - const pbnPrfRes = await getFetch(fetchType.WR, "/reuseFc/pubFc"); - set((state: pbnstPrfmncState) => ({ + const prfmncRes = await fetchPbnstPrfRegList(page, sggOrCity); + return set((state: pbnstPrfmncState) => ({ ...state, prfmnc: { ...state.prfmnc, - prfPbnFclts: pbnPrfRes, + prfmncRes: prfmncRes.data, + prfmncResCount: prfmncRes.count, + prfmncDetail: null, + isInit: false, }, })); } catch (error) { console.log(error); } }, - prfmncHasuInit: async ( - page: number, - sggOrCity: string, - year: string, - prfPbnFclts: string[] - ) => { - try { - const prfmncRes = await hasuListFetch( - page, - sggOrCity, - year, - prfPbnFclts - ); - return set((state: pbnstPrfmncState) => ({ - ...state, - prfmnc: { - ...state.prfmnc, - prfmncRes: prfmncRes.data, - prfmncResCount: prfmncRes.count, - isInit: false, - }, - })); - } catch (errorHasu) { - console.log(errorHasu); - } - }, prfmncHasuNmSearch: async ( page: number, sggOrCity: string, - year: string, - searchTxt: string, - prfPbnFclts: string[] + searchTxt: string ) => { try { - const prfmncRes = await hasuListFetch( - page, - sggOrCity, - year, - prfPbnFclts, - searchTxt - ); + const prfmncRes = await fetchPbnstPrfRegList(page, sggOrCity, searchTxt); return set((state: pbnstPrfmncState) => ({ ...state, prfmnc: { ...state.prfmnc, prfmncRes: prfmncRes.data, prfmncResCount: prfmncRes.count, + prfmncDetail: null, isInit: false, }, })); - } catch (errorHasu) { - console.log(errorHasu); + } catch (error) { + console.log(error); } }, - prfmnRegData: async ( - page: number, - sggOrCity: string, - data: PrfType, - year: string, - prfPbnFclts: string[] - ) => { + getPbnstPrfRegDtl: async (year: string, pbnstRuseFcltId: string) => { + try { + const detail = (await getFetch( + fetchType.WR, + `/reuseFc/pbnstPrfRegDtl/${year}/${pbnstRuseFcltId}` + )) as PbnstPrfRegDtlType; + + set((state: pbnstPrfmncState) => ({ + ...state, + prfmnc: { + ...state.prfmnc, + prfmncDetail: detail, + }, + })); + + return detail; + } catch (error) { + console.log(error); + return undefined; + } + }, + prfmnRegData: async (data: PrfType[], year: string, pbnstRuseFcltId: string) => { const failMessage = "하수재이용 시설 실적 등록이 실패하였습니다."; try { - const ressponse = await postFectch( + const response = await postFectch( "/reuseFc/pubFcPrf", data, fetchType.WR ); - if (ressponse.status === 200) { - const prfmncRes = await hasuListFetch( - page, - sggOrCity, - year, - prfPbnFclts - ); + if (response.status === 200) { + const detail = (await getFetch( + fetchType.WR, + `/reuseFc/pbnstPrfRegDtl/${year}/${pbnstRuseFcltId}` + )) as PbnstPrfRegDtlType; set((state: pbnstPrfmncState) => ({ ...state, prfmnc: { ...state.prfmnc, - prfmncRes: prfmncRes.data, - prfmncResCount: prfmncRes.count, + prfmncDetail: detail, isInit: false, pageKey: state.prfmnc.pageKey + 1, }, diff --git a/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/type.tsx b/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/type.tsx index 6723f3922..ff53a381f 100644 --- a/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/type.tsx +++ b/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/type.tsx @@ -54,8 +54,26 @@ export const FcPrfSchema = z.object({ bscFctNm: z.string().nullish(), }); +export const PbnstPrfRegListSchema = z.object({ + swageBscFctNm: z.string().default(""), + ruseUtztnFcltNm: z.string().default(""), + pbnstRuseFcltId: z.string().default(""), + sidoCd: z.string().default(""), + sggCd: z.string().default(""), + totalCount: z.number().default(0), +}); + +export const PbnstPrfRegDtlSchema = z.object({ + pbnstRuseFcltId: z.string().default(""), + ruseUtztnFcltNm: z.string().default(""), + swageBscFctNm: z.string().default(""), + prfList: z.array(PrfSchema).default([]), +}); + export type FcPrfType = z.infer; export type PrfType = z.infer; +export type PbnstPrfRegListType = z.infer; +export type PbnstPrfRegDtlType = z.infer; export const initValInUsage:PrfUsageSummary[] = [ { diff --git a/apps/wr/components/ui/modal/chkgd/pbnPrfExcelModal/pbnPrfExcelModal.tsx b/apps/wr/components/ui/modal/chkgd/pbnPrfExcelModal/pbnPrfExcelModal.tsx index d1d4fcd59..25d80794f 100644 --- a/apps/wr/components/ui/modal/chkgd/pbnPrfExcelModal/pbnPrfExcelModal.tsx +++ b/apps/wr/components/ui/modal/chkgd/pbnPrfExcelModal/pbnPrfExcelModal.tsx @@ -4,7 +4,7 @@ import { excelToPbnPrfData } from "@/app/store/prfmncReg/pbnstPrfmncReg/excel"; import { pbnPrfRegExcelZod } from "@/app/store/prfmncReg/pbnstPrfmncReg/headList"; import type { - FcPrfType, + PbnstPrfRegDtlType, PrfType, } from "@/app/store/prfmncReg/pbnstPrfmncReg/type"; import { yearOptions } from "@/components/lib/func/common"; @@ -18,7 +18,7 @@ import { } from "@common/business_components/ui"; import { Button, Input } from "@common/components/ui"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; // import toast from "react-hot-toast"; import type { z } from "zod"; @@ -29,13 +29,14 @@ export default function PbnPrfExcelModal({ setIsExcelOPen, setModalExcelData, }: { - data: FcPrfType; + data: PbnstPrfRegDtlType; setIsExcelOPen: (open: boolean) => void; - setModalExcelData: (excelData: PrfType) => void; + setModalExcelData: (excelData: PrfType[]) => void; }) { const alert = useAlertStore((state) => state.setMessage); const [file, setFile] = useState(null); const [excelData, setExcelData] = useState(); + const fileInputRef = useRef(null); const form = useForm>({ resolver: zodResolver(pbnPrfRegExcelZod), defaultValues: { @@ -44,26 +45,32 @@ export default function PbnPrfExcelModal({ }); const handleFileChange = (e: React.ChangeEvent) => { + setExcelData(undefined); const selectedFile = e.target.files?.[0]; if (selectedFile) { setFile(selectedFile); } }; const onSubmit = (values: z.infer) => { - if (excelData) { - ////TODO: 궅이 서브밋을 써야하는 이유가 있는지 ? - console.log("values ", values); - // prfmnRegData(1,'41', excelData as unknown as PrfType); 활성화시 스토어 활성화 칠요 - - setModalExcelData(excelData as unknown as PrfType); - setIsExcelOPen(false); + if (!file) { + alert("엑셀 파일을 선택해 주세요."); + return; } + + if (!excelData || excelData.length === 0) { + alert("엑셀 파일을 먼저 확인해 주세요."); + return; + } + + ////TODO: 궅이 서브밋을 써야하는 이유가 있는지 ? + console.log("values ", values); + setModalExcelData(excelData); + setIsExcelOPen(false); }; useEffect(() => { - if (data && data.bscFctNm) - form.setValue("pbnstRuseFcltId", data.pbnstRuseFcltId); - form.setValue("bscFctNm", data.bscFctNm || ""); - form.setValue("pbnstRuseFcltNm", data.ruseUtztnFcltNm); + form.setValue("pbnstRuseFcltId", data.pbnstRuseFcltId || ""); + form.setValue("bscFctNm", data.swageBscFctNm || ""); + form.setValue("pbnstRuseFcltNm", data.ruseUtztnFcltNm || ""); }, [data]); useEffect(() => { @@ -76,20 +83,42 @@ export default function PbnPrfExcelModal({ return; } + if (!form.getValues("pbnstRuseFcltId")) { + alert("재이용시설을 다시 선택한 뒤 파일등록을 진행해 주세요."); + setFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + return; + } + if (isFile) { filePicked(isFile, 0).then((res) => { - if (res !== undefined) - setExcelData( - excelToPbnPrfData( - res as unknown as JSON[], - form.getValues("ym"), - form.getValues("pbnstRuseFcltId") - ) as PrfType[] - ); + if (res === undefined) { + alert("엑셀 파일을 읽지 못했습니다."); + return; + } + + const parsedData = excelToPbnPrfData( + res as unknown as JSON[], + form.getValues("ym"), + form.getValues("pbnstRuseFcltId") + ) as PrfType[]; + + if (!parsedData.length) { + alert("업로드 가능한 실적 데이터가 없습니다. 양식을 확인해 주세요."); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + setFile(null); + return; + } + + setExcelData(parsedData); }); } } - }, [file]); + }, [alert, file, form]); return ( <> @@ -177,10 +206,11 @@ export default function PbnPrfExcelModal({
{ + onChange={(event) => { console.log(" field. valus", field.value); - if (file) handleFileChange(file); + handleFileChange(event); }} accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel" /> diff --git a/apps/wr/docs/기준문서/하수재이용실적 등록 api 호출분석_20260630.md b/apps/wr/docs/기준문서/하수재이용실적 등록 api 호출분석_20260630.md new file mode 100644 index 000000000..81f46eb5a --- /dev/null +++ b/apps/wr/docs/기준문서/하수재이용실적 등록 api 호출분석_20260630.md @@ -0,0 +1,245 @@ +# 하수재이용실적 등록 API 호출 분석 + +작성일: 2026-06-30 +## 0. 공통코드 조회 +- 가장 메인 진입점은 [apps/wr/src/app/providers/app-provider.tsx (line 38)](/D:/iipbs/frontend/apps/wr/src/app/providers/app-provider.tsx:38) 의 CommonCodeInitializer +- 실제 공통코드 조회 액션은 [apps/wr/app/store/common/code/index.ts (line 372)](/D:/iipbs/frontend/apps/wr/app/store/common/code/index.ts:372) +- +## 1. 대상 화면 + +- 화면: `재이용실적등록 > 하수재이용실적등록` +- 페이지 파일: [apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:47) +- 스토어 파일: [apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:1) + +## 2. 분석 결론 요약 + +- 좌측 `하수재이용시설 목록`은 단일 API가 아니라 2개 API를 조합해 생성된다. +- 우측 `하수처리수재이용 실적(m³) 등록결과`는 별도 조회 API를 직접 호출하지 않는다. +- 우측 화면의 원본 데이터는 좌측 목록 조회 시 함께 내려오는 `prfList`이다. +- 우측 `저장`은 `POST /reuseFc/pubFcPrf`를 호출하고, 저장 성공 후 좌측 목록 조회 로직을 다시 호출해 화면을 갱신한다. + +## 3. 좌측 하수재이용시설 목록 API 호출 분석 + +### 3.1 화면 진입 시 호출 순서 + +1. 권한 코드 조회 + - `getLaeSeCdCheck()` + - 호출 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:206) +2. WR에 등록된 하수재이용시설 기준 코드 목록 조회 + - `getPrfFmcRes()` + - 호출 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:210) +3. 좌측 목록 초기 조회 + - `prfmncHasuInit(1, laeSeCd, year, prfPbnFclts)` + - 호출 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:217) + +### 3.2 기준 시설코드 목록 조회 + +`getPrfFmcRes()`는 먼저 WR에서 등록된 시설코드 목록을 가져온다. + +- 함수 위치: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:130) +- 호출 API: + +```text +GET /reuseFc/pubFc +``` + +- 목적: + - WR에 등록된 하수재이용시설의 기반 시설코드 목록 `prfPbnFclts`를 확보 + - 이후 좌측 목록 조회 시 FO/Common과 WR 조회의 기준 배열로 사용 + +### 3.3 좌측 목록 실제 조회 함수 + +좌측 목록은 `prfmncHasuInit()` 또는 `prfmncHasuNmSearch()`가 내부적으로 `hasuListFetch()`를 호출해 구성한다. + +- `prfmncHasuInit()` 위치: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:144) +- `prfmncHasuNmSearch()` 위치: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:170) +- 공통 실제 조회 함수 위치: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:72) + +### 3.4 1차 호출: FO/Common 하수처리시설 기본정보 조회 + +`hasuListFetch()`는 먼저 `prfPbnFclts`를 `sggOrCity` 기준으로 필터링한다. + +- 관련 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:79) +- 필터 기준: + - `sggOrCity` 길이 2: 시도 코드 비교 + - 그 외: 앞 5자리 시군구 코드 비교 + +그 후 FO/Common API를 호출한다. + +```text +POST /prcsFclt/ruseInfo/1/{filteredFclt.length + 1} +``` + +- 호출 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:88) +- 실제 호출부: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:92) +- 요청 바디: + - `filteredFclt` + - WR 기준코드 목록 배열 + +이름 검색 시에는 URL 뒤에 검색어가 붙는다. + +```text +POST /prcsFclt/ruseInfo/1/{filteredFclt.length + 1}/{searchTxt} +``` + +- 관련 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:89) + +이 호출의 목적은 다음과 같다. + +- 하수처리시설의 기본정보 확보 + - `sido` + - `gugun` + - `bscFctNm` + - `bscFctCd` +- 검색어가 있을 경우 실제 검색 결과 시설코드 집합 재산정 + +### 3.5 2차 호출: WR 하수재이용시설 상세 및 실적 조회 + +1차 FO/Common 조회 결과를 바탕으로 WR API를 호출한다. + +```text +POST /reuseFc/pubDetailList/{year}/{sggOrCity}/{page}/{prfRegPageSize} +``` + +- 기본 페이지 크기: `prfRegPageSize = 10` +- 관련 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:19) +- 호출 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:99) +- 요청 바디: + - 검색어 없을 때: `filteredFclt` + - 검색어 있을 때: `rainRes.data.map((item) => item.bscFctCd)` + +즉 검색 시에는 FO/Common 검색 결과에서 살아남은 시설코드만 WR로 다시 전달한다. + +### 3.6 좌측 목록 최종 데이터 생성 방식 + +WR 응답 `pubRuseList.data`와 FO/Common 응답 `rainRes.data`를 다음 기준으로 매핑한다. + +- 매핑 키: + - `item.pbnstRuseFcltId.substring(0, 11)` + - `fclt.bscFctCd` + +- 관련 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:104) + +최종 좌측 목록 `prfmncRes`에는 다음 필드가 들어간다. + +- `sido` +- `gugun` +- `bscFctNm` +- `bscFctCd` +- `ruseUtztnFcltNm` +- `pbnstRuseFcltId` +- `prfList` + +여기서 중요한 점은 `prfList`가 좌측 목록 데이터에 이미 포함된다는 점이다. + +## 4. 우측 하수처리수재이용 실적(m³) 등록결과 조회 분석 + +### 4.1 결론 + +우측 `하수처리수재이용 실적(m³) 등록결과`는 별도 조회 API를 호출하지 않는다. + +우측 테이블의 데이터 원본은 좌측 목록 조회 결과 `prfmncRes[index].prfList`이다. + +### 4.2 데이터 생성 위치 + +좌측 행 클릭 시 `cellClick()`이 실행된다. + +- 함수 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:116) +- 호출 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:326) + +`cellClick()` 내부 동작: + +1. 선택한 행의 `prfmncRes[index]`를 읽음 +2. 그 안의 `prfList`를 순회 +3. 현재 선택 연도 `searchYear`와 일치하는 월별 데이터만 추출 +4. `장내용수`, `장외용수`, `소계`, `총합계` 구조로 `inputData` 재구성 +5. 우측 테이블은 `inputData`를 그대로 렌더링 + +우측 렌더링 위치: + +- [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:356) + +따라서 우측 등록결과는 조회 API 기준으로 보면 다음 호출의 간접 결과다. + +```text +POST /reuseFc/pubDetailList/{year}/{sggOrCity}/{page}/{10} +``` + +## 5. 우측 저장 API 호출 분석 + +### 5.1 저장 버튼 호출 경로 + +우측 `저장` 버튼 클릭 시 `onSave()`가 실행된다. + +- 버튼 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:282) +- 저장 함수 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:93) + +`onSave()`는 화면 편집 데이터 `inputData`를 `excelToPbnPrfData()`로 변환한 뒤 `prfmnRegData()`에 전달한다. + +- 변환 코드: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:100) + +### 5.2 실제 저장 API + +`prfmnRegData()`에서 실제 저장 호출을 수행한다. + +- 함수 위치: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:198) + +```text +POST /reuseFc/pubFcPrf +``` + +- 호출 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:207) +- 요청 바디: + - `PrfType` + - `excelToPbnPrfData(inputData, searchYear, pbnstRuseFcltId)` 변환 결과 + +### 5.3 저장 성공 후 후속 조회 + +저장 성공 시 `prfmnRegData()`는 종료하지 않고 다시 `hasuListFetch()`를 호출한다. + +- 관련 코드: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:213) + +즉 저장 직후 다음 흐름으로 재조회가 일어난다. + +1. `POST /reuseFc/pubFcPrf` 저장 +2. `hasuListFetch()` 재실행 +3. 내부에서 다시 FO/Common + WR 조회 수행 +4. `prfmncRes`, `prfmncResCount` 갱신 +5. `pageKey` 증가 +6. 우측 테이블 `TableBody` 재렌더링 + +관련 코드: + +- 상태 갱신: [index.tsx](/D:/iipbs/frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx:221) +- 우측 `TableBody` key: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:360) + +## 6. 파일등록 엑셀 저장도 동일 API 사용 + +파일등록 모달에서도 저장 시 동일한 `prfmnRegData()`를 호출한다. + +- 호출 위치: [page.tsx](/D:/iipbs/frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx:381) + +즉 수기 입력 저장과 엑셀 등록 저장은 최종적으로 같은 API를 사용한다. + +```text +POST /reuseFc/pubFcPrf +``` + +## 7. 화면 이벤트별 API 요약 + +| 화면 이벤트 | 호출 함수 | 실제 API | +| --- | --- | --- | +| 화면 진입 | `getPrfFmcRes()` | `GET /reuseFc/pubFc` | +| 화면 진입 후 좌측 목록 초기조회 | `prfmncHasuInit()` | `POST /prcsFclt/ruseInfo/...` + `POST /reuseFc/pubDetailList/...` | +| 좌측 목록 이름검색 | `prfmncHasuNmSearch()` | `POST /prcsFclt/ruseInfo/.../{searchTxt}` + `POST /reuseFc/pubDetailList/...` | +| 좌측 행 클릭 | `cellClick()` | 별도 API 없음, `prfmncRes[index].prfList` 사용 | +| 우측 저장 | `prfmnRegData()` | `POST /reuseFc/pubFcPrf` 후 `POST /prcsFclt/ruseInfo/...` + `POST /reuseFc/pubDetailList/...` 재조회 | +| 엑셀 파일등록 저장 | `prfmnRegData()` | `POST /reuseFc/pubFcPrf` 후 재조회 | + +## 8. 확인이 필요한 사항 + +- `POST /prcsFclt/ruseInfo/1/{filteredFclt.length + 1}` 에서 `+ 1`을 주는 이유는 코드상 명확한 설명이 없다. +- 저장 후 재조회 시 현재 페이지를 유지하지 않고, 호출부에서 전달한 페이지 값에 의존한다. + - 현재 수기 저장과 엑셀 저장 모두 `1` 페이지로 호출하고 있다. +- 우측 화면은 독립 조회 API가 없으므로, `pubDetailList` 응답 구조가 바뀌면 좌우 화면이 함께 영향을 받는다. + diff --git a/apps/wr/docs/작업계획/하수처리수재이용 실적등록_20260630.md b/apps/wr/docs/작업계획/하수처리수재이용 실적등록_20260630.md new file mode 100644 index 000000000..def2d6d0b --- /dev/null +++ b/apps/wr/docs/작업계획/하수처리수재이용 실적등록_20260630.md @@ -0,0 +1,285 @@ +# 하수처리수재이용 실적등록 작업계획 + +작성일: 2026-06-30 + +## 1. 대상 + +- 화면 + - `frontend/apps/wr/app/(with-layout)/ruseFcltMng/prfmncReg/pbnstPrfmncReg/page.tsx` +- 프론트 스토어 + - `frontend/apps/wr/app/store/prfmncReg/pbnstPrfmncReg/index.tsx` +- 백엔드 + - `D:\iipbs\wr_back_out` +- 기준문서 + - `frontend/apps/wr/docs/기준문서/하수재이용실적 등록 api 호출분석_20260630.md` + +## 2. 요청사항 정리 + +- 좌측 목록은 `tb_wr_pbnst_ruse_fclt`만 기준으로 조회한다. +- 좌측 목록은 `reuse-service` 1회 호출로 조회한다. +- 시도/시군구 명칭은 공통코드 조회값을 사용해 화면에서 표시한다. +- 우측 `하수처리수재이용 실적(m³) 등록결과`는 좌측 시설 클릭 시 별도 API를 호출해 조회한다. +- 현재처럼 좌측 목록 조회 시 실적 `prfList`까지 함께 내려받는 구조는 제거한다. +- 좌측 신규 API명은 `reuseFc/pbnstPrfRegList`로 맞춘다. + +## 3. 현재 구조 확인 결과 + +### 3.1 프론트 + +- 화면 진입 시 현재 호출 흐름 + - `GET /reuseFc/pubFc` + - `POST /prcsFclt/ruseInfo/...` + - `POST /reuseFc/pubDetailList/{year}/{sggCd}/{pageNo}/{pageCnt}` +- 좌측 목록은 FO/Common 응답과 WR 응답을 프론트에서 합성한다. +- 우측 실적은 별도 API를 호출하지 않고 좌측 목록의 `prfList`를 그대로 사용한다. +- 저장 후에도 다시 좌측 전체 조회를 수행한다. + +### 3.2 reuse-service + +- 이미 존재하는 API + - `GET /reuseFc/pubFc` + - 현재는 `pbnst_ruse_fclt_id`의 마지막 1자리를 제거한 기초시설코드 목록만 반환한다. + - 좌측 목록 API로는 재사용 불가하다. + - `POST /reuseFc/pubDetailList/{year}/{sggCd}/{pageNo}/{pageCnt}` + - 시설 기본정보 + `prfList`를 함께 반환한다. + - 우측 클릭 조회용으로는 과조회이며 현재 구조의 결합 원인이다. + - `GET /reuseFc/pbnstList/...` + - WR 기준 목록이지만 `prfList`, 운영정보, 합계까지 같이 내려준다. + - 좌측 단순 목록 전용 API로는 과하다. +- 결론 + - 요청사항에 정확히 맞는 좌측 목록 API는 현재 없음 + - `reuseFc/pbnstPrfRegList` 신규 추가가 필요함 + +## 4. 목표 구조 + +### 4.1 좌측 목록 + +- 조회원천 + - `sm_bs_wr.tb_wr_pbnst_ruse_fclt` +- 호출 + - `reuse-service` 1회 +- 응답 범위 + - 시설명 + - 재사용시설명 + - 재사용시설코드 + - 시도코드 + - 시군구코드 + - 필요 시 페이징용 totalCount +- 화면 표시 + - 시도/시군구 명칭은 공통 코드 스토어에서 코드명으로 변환 + +### 4.2 우측 실적 + +- 조회시점 + - 좌측 행 클릭 시 +- 조회원천 + - `sm_bs_wr.tb_wr_pbnst_ruse_fclt_prfmnc` +- 응답범위 + - 선택 시설의 해당 연도 월별 실적만 반환 +- 저장 후 처리 + - 저장 성공 시 좌측 목록 전체 재조회 대신 우측 상세만 재조회 + - 좌측 목록은 변경 필요가 없으므로 불필요한 재호출 제거 + +## 5. 좌측 API 설계안 + +### 5.1 API 존재 여부 판단 + +- `GET /reuseFc/pubFc`: 부적합 + - 코드목록만 반환 +- `GET /reuseFc/pbnstList/...`: 부적합 + - `prfList`와 운영정보까지 포함 +- 따라서 신규 API 추가 + +### 5.2 API 경로 + +- `GET /reuseFc/pbnstPrfRegList/{pageNo}/{pageCnt}/{searchingCd}/{nameCd}` +- 이름검색이 필요하면 query parameter로 확장 + - 예: `?swtrmtFcltNm=...` + +### 5.3 조회조건 + +- `pageNo` +- `pageCnt` +- `searchingCd` + - 기본은 행정구역 기준 `A` +- `nameCd` + - `0`: 전체 + - `NN`: 시도코드 + - `NNNNN`: 시군구코드 +- `swtrmtFcltNm` + - 시설명 검색어 + +### 5.4 응답 컬럼 + +- `swageBscFctNm` + - 시설명 +- `ruseUtztnFcltNm` + - 재사용시설명 +- `pbnstRuseFcltId` + - 재사용시설코드 +- `sidoCd` + - `substring(swage_adst_dstr_cd, 1, 2)` +- `sggCd` + - `swage_adst_dstr_cd` +- `totalCount` + +### 5.5 DB 매핑 기준 + +- 시설명 + - `swage_bsc_fct_nm` +- 재사용시설명 + - `ruse_utztn_fclt_nm` +- 재사용시설코드 + - `pbnst_ruse_fclt_id` +- 시도코드 + - `swage_adst_dstr_cd` 앞 2자리 +- 시군구코드 + - `swage_adst_dstr_cd` + +### 5.6 SQL 방향 + +- `tb_wr_pbnst_ruse_fclt`만 조회 +- `prfList`, `operList`, 합계 컬럼 조인 제거 +- 정렬은 `pbnst_ruse_fclt_id` 기준 +- 검색은 `swage_bsc_fct_nm like '%' || #{swtrmtFcltNm} || '%'` + +## 6. 우측 API 설계안 + +### 6.1 기존 API 분석 + +- 현재 우측에 사용되는 데이터 원본은 `POST /reuseFc/pubDetailList/{year}/{sggCd}/{pageNo}/{pageCnt}` 응답의 `prfList`다. +- 즉 기존 응답에서 실제 우측 화면이 쓰는 값은 아래뿐이다. + - `pbnstRuseFcltId` + - `ym` + - `wshwtr` + - `coolUswtr` + - `cleanUswtr` + - `drwtUswtr` + - `dltnUswtr` + - `ithlRswtr` + - `ithlEtcUswtr` + - `cleanToilUswtr` + - `resn` + - `lndwtr` + - `hydUswtr` + - `rvrKeepUswtr` + - `farmUswtr` + - `ugwtChr` + - `indstUswtr` + +### 6.2 신규 상세 API 방향 + +- 우측은 클릭 시 시설 1건만 조회하므로 시설 단건 + 연도 조건 API로 분리한다. +- 권장 경로 + - `GET /reuseFc/pbnstPrfRegDtl/{year}/{pbnstRuseFcltId}` +- 최종 명칭은 구현 시 팀 네이밍 규칙에 맞춰 확정한다. + +### 6.3 요청 파라미터 + +- `year` + - 조회연도 + - 예: `2026` +- `pbnstRuseFcltId` + - 선택한 재사용시설코드 + - 예: `11000SW001R1` + +### 6.4 응답값 + +- 시설식별 정보 + - `pbnstRuseFcltId` + - `ruseUtztnFcltNm` + - `swageBscFctNm` +- 실적목록 + - `prfList: PublicFcPrfmnc[]` +- `prfList` 원소 구조 + - `ym` + - `wshwtr` + - `coolUswtr` + - `cleanUswtr` + - `drwtUswtr` + - `dltnUswtr` + - `ithlRswtr` + - `ithlEtcUswtr` + - `cleanToilUswtr` + - `resn` + - `lndwtr` + - `hydUswtr` + - `rvrKeepUswtr` + - `farmUswtr` + - `ugwtChr` + - `indstUswtr` + +### 6.5 SQL 방향 + +- `tb_wr_pbnst_ruse_fclt_prfmnc`를 `pbnst_ruse_fclt_id`, `ym like #{year} || '%'` 조건으로 조회 +- 필요 시 시설명/재사용시설명 표시를 위해 `tb_wr_pbnst_ruse_fclt` 1회 join +- 반환 단위는 1개 시설 + 최대 12개월 실적 + +## 7. 프론트 변경계획 + +### 7.1 스토어 + +- `getPrfFmcRes()` 제거 + - 더 이상 `GET /reuseFc/pubFc` 선조회 불필요 +- `hasuListFetch()` 제거 또는 전면 교체 + - FO/Common + WR 합성 로직 삭제 +- 좌측 목록 조회 함수 신설 + - `GET /reuseFc/pbnstPrfRegList/...` +- 우측 상세 조회 함수 신설 + - 좌측 클릭 시 상세 API 호출 +- 저장 후 재조회 변경 + - 기존: 좌측 전체 목록 재조회 + - 변경: 우측 상세 재조회 + +### 7.2 페이지 + +- 최초 진입 시 좌측 목록만 조회 + 첫시설의 우측 상세 호출. 첫시설 선택 표시. +- 좌측 클릭 시 우측 상세 호출 +- `cellClick()` 내부의 `prfmncRes[index].prfList` 직접 사용 제거 +- 좌측 row 데이터는 코드 중심으로 유지 + - `sidoCd`, `sggCd`를 공통코드명으로 변환해 표시 + +## 8. 백엔드 변경계획 + +### 8.1 대상 파일 + +- `src/main/java/kr/co/mindone/wrout/controller/PublicFcController.java` +- `src/main/java/kr/co/mindone/wrout/service/public_fc/PublicFcService.java` +- `src/main/java/kr/co/mindone/wrout/mapper/wr/PublicFcMapper.java` +- `src/main/resources/postgres/mapper/wr/public_fc_select.xml` +- 필요 시 신규 DTO + - 좌측 목록 DTO + - 우측 상세 DTO + +### 8.2 구현 항목 + +1. 좌측 목록 DTO 추가 + - 실적 없는 가벼운 응답 모델 +2. `pbnstPrfRegList` 컨트롤러/서비스/매퍼 추가 +3. 우측 단건 실적 조회 API 추가 +4. `pubDetailList`는 기존 화면 영향 범위 확인 중요함. 그 후에 유지 혹은 신규 추가. +5. Swagger 설명 추가 + +## 9. 검증 항목 + +1. 화면 진입 시 좌측 목록 API 1회만 호출되는지 +2. 화면 진입 시 `POST /prcsFclt/ruseInfo/...`가 더 이상 호출되지 않는지 +3. 좌측 목록 응답에 `prfList`가 포함되지 않는지 +4. 좌측 클릭 시에만 우측 상세 API가 호출되는지 +5. 저장 후 좌측 목록 재조회 없이 우측 데이터만 갱신되는지 +6. 시도/시군구 명칭이 공통 코드 기준으로 정상 표시되는지 +7. 기존 저장 API `POST /reuseFc/pubFcPrf`와 정합성 문제가 없는지 + +## 10. 작업 순서 + +1. `reuse-service`에 좌측 목록 신규 API 추가 +2. `reuse-service`에 우측 상세 신규 API 추가 +3. 프론트 스토어를 신규 API 기준으로 분리 +4. 페이지 클릭/저장 후 갱신 흐름 수정 +5. 수동 테스트로 호출 수와 데이터 정합성 확인 + +## 11. 결론 + +- 좌측 목록 전용 API `reuseFc/pbnstPrfRegList`는 현재 `reuse-service`에 없으므로 신규 추가가 필요하다. +- 우측 실적도 현재는 `pubDetailList`에 묶여 있으므로, 시설 클릭 시 조회하는 단건 상세 API로 분리하는 것이 맞다. +- 이번 작업의 핵심은 "좌측은 시설목록만, 우측은 클릭 후 실적조회"로 API 책임을 분리해 화면 구조와 호출 구조를 단순화하는 것이다. diff --git a/apps/wr/src/app/styles/components-black.css b/apps/wr/src/app/styles/components-black.css index 109df260b..e8c90d479 100644 --- a/apps/wr/src/app/styles/components-black.css +++ b/apps/wr/src/app/styles/components-black.css @@ -125,7 +125,12 @@ background: #1a1c20; } - + input[type="file"] { + background-color: #1a1a1a; + border-color: #262626; + color: #f1f1f1; + } + } input[type="file"] { diff --git a/packages/common/business_components/src/ui/chart/BarChart.tsx b/packages/common/business_components/src/ui/chart/BarChart.tsx index ec0647dff..486cc8a7c 100644 --- a/packages/common/business_components/src/ui/chart/BarChart.tsx +++ b/packages/common/business_components/src/ui/chart/BarChart.tsx @@ -53,13 +53,13 @@ const CustomTooltip = ({ }) => { if (active && payload && payload.length) { return ( -
-

{label}

+
+

{label}

{payload.map((item, index) => (
{/* 색상 박스 추가 (범례 색상과 동일) */}
- {` ${item.name} : ${fncNumberFormet(item.value)}`} + {` ${item.name} : ${fncNumberFormet(item.value)}`}
))}
@@ -76,7 +76,7 @@ const CustomLegend = (props: any) => { {payload.map((entry: unknown, index: number) => (
- {(entry as {value: string}).value} + {(entry as {value: string}).value}
))}
@@ -141,7 +141,7 @@ export default function Chart({chartConfig=undefined, chartData=[], chartTitle=' radius={5} key={key} barSize={graphSize} - fill={colors[idx % colors.length]} + fill={colors[idx % colors.length] || "var(--foreground)"} /> )) )} @@ -151,4 +151,4 @@ export default function Chart({chartConfig=undefined, chartData=[], chartTitle='
) -} \ No newline at end of file +} diff --git a/packages/common/business_components/src/ui/chart/DoubleBar.tsx b/packages/common/business_components/src/ui/chart/DoubleBar.tsx index be930f3af..598a5224f 100644 --- a/packages/common/business_components/src/ui/chart/DoubleBar.tsx +++ b/packages/common/business_components/src/ui/chart/DoubleBar.tsx @@ -44,13 +44,13 @@ const CustomTooltip = ({ }) => { if (active && payload && payload.length) { return ( -
-

{label}

+
+

{label}

{payload.map((item, index) => (
{/* 색상 박스 추가 (범례 색상과 동일) */}
- {` ${item.name} : ${item.value}`} + {` ${item.name} : ${item.value}`}
))}
@@ -67,7 +67,7 @@ const CustomLegend = (props: any) => { {payload.map((entry: unknown, index: number) => (
- {(entry as {value: string}).value} + {(entry as {value: string}).value}
))}
@@ -123,7 +123,7 @@ export default function DoubleBarChart({chartConfig=undefined, chartData=[], cha key={key} stackId="a" barSize={graphSize} - fill={colors[idx]} + fill={colors[idx] || "var(--foreground)"} /> )) )} @@ -133,4 +133,4 @@ export default function DoubleBarChart({chartConfig=undefined, chartData=[], cha
) -} \ No newline at end of file +} diff --git a/packages/common/business_components/src/ui/chart/DoughnutChart.tsx b/packages/common/business_components/src/ui/chart/DoughnutChart.tsx index ec8fc2fd9..1692f79fd 100644 --- a/packages/common/business_components/src/ui/chart/DoughnutChart.tsx +++ b/packages/common/business_components/src/ui/chart/DoughnutChart.tsx @@ -69,7 +69,7 @@ const renderCustomizedLabel = ({ cx ? "start" : "end"} dominantBaseline="central" @@ -87,12 +87,12 @@ const CustomTooltip = ({ active, payload, label }: { }) => { if (active && payload && payload.length) { return ( -
-

{label}

+
+

{label}

{payload.map((item, index) => (
- {` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`} + {` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}
))}
@@ -126,7 +126,7 @@ const CustomLegend = (props: LegendProps) => { minHeight: "14px", }} >
- + {entry.payload?.label || entry.value} diff --git a/packages/common/business_components/src/ui/chart/LineBarChart.tsx b/packages/common/business_components/src/ui/chart/LineBarChart.tsx index 271629e6a..ae855dfb0 100644 --- a/packages/common/business_components/src/ui/chart/LineBarChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineBarChart.tsx @@ -49,13 +49,13 @@ type LegendProps = { const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => { if (active && payload && payload.length) { return ( -
-

{label}

+
+

{label}

{payload.map((item, index) => (
{/* 색상 박스 추가 (범례 색상과 동일) */}
- {` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`} + {` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}
))}
@@ -74,7 +74,7 @@ const CustomLegend = (props: LegendProps) => { {payload.map((entry: LegendEntry, index: number) => (
- {entry.value} + {entry.value}
))}
@@ -172,9 +172,10 @@ export default function LineBarchart({ x={x - padding} // 맨 좌측 바깥 y={y + height / 2 - 12} // 수직 중앙 textAnchor="middle" - transform={`rotate(-90, ${x - padding}, ${y + height / 2})`} - className="text-[12px] font-bold" - > + transform={`rotate(-90, ${x - padding}, ${y + height / 2})`} + className="text-[12px] font-bold" + fill="var(--muted-foreground)" + > {fieldText}
); @@ -184,9 +185,10 @@ export default function LineBarchart({ x={x + height / 2 } // 맨 좌측 바깥 y={y - width + padding } // 수직 중앙 textAnchor="middle" - transform={`rotate(90, ${x}, ${y})`} - className="text-[12px] font-bold" - > + transform={`rotate(90, ${x}, ${y})`} + className="text-[12px] font-bold" + fill="var(--muted-foreground)" + > {fieldText} ); @@ -249,7 +251,7 @@ export default function LineBarchart({ key={`bar-${key}-${index}`} yAxisId="right" dataKey={key} - fill={config?.color || "black"} + fill={config?.color || "var(--foreground)"} name={String(config?.label || "Y축")} maxBarSize={80} /> @@ -267,7 +269,7 @@ export default function LineBarchart({ yAxisId="left" type="linear" dataKey={key} - stroke={config?.color || "black"} + stroke={config?.color || "var(--foreground)"} strokeWidth={2} dot={{r: 0}} name={String(config?.label || "X축")} @@ -281,4 +283,4 @@ export default function LineBarchart({ ); -} \ No newline at end of file +} diff --git a/packages/common/business_components/src/ui/chart/LineBarChart2.tsx b/packages/common/business_components/src/ui/chart/LineBarChart2.tsx index ff8e6b5cb..b0ae7a0c3 100644 --- a/packages/common/business_components/src/ui/chart/LineBarChart2.tsx +++ b/packages/common/business_components/src/ui/chart/LineBarChart2.tsx @@ -46,13 +46,13 @@ type LegendProps = { const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => { if (active && payload && payload.length) { return ( -
-

{label}

+
+

{label}

{payload.map((item, index) => (
{/* 색상 박스 추가 (범례 색상과 동일) */}
- {` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`} + {` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}
))}
@@ -71,7 +71,7 @@ const CustomLegend = (props: LegendProps) => { {payload.map((entry: LegendEntry, index: number) => (
- {entry.value} + {entry.value}
))}
@@ -151,7 +151,7 @@ export default function LineBarchart2({ key={`bar-${key}-${index}`} yAxisId="right" dataKey={key} - fill={config?.color || "black"} + fill={config?.color || "var(--foreground)"} name={String(config?.label || "Y축")} maxBarSize={80} /> @@ -169,7 +169,7 @@ export default function LineBarchart2({ yAxisId="left" type="linear" dataKey={key} - stroke={config?.color || "black"} + stroke={config?.color || "var(--foreground)"} strokeWidth={2} dot={{r: 0}} name={String(config?.label || "X축")} @@ -183,4 +183,4 @@ export default function LineBarchart2({ ); -} \ No newline at end of file +} diff --git a/packages/common/business_components/src/ui/chart/LineChart.tsx b/packages/common/business_components/src/ui/chart/LineChart.tsx index b9ba58ff6..036dbfae4 100644 --- a/packages/common/business_components/src/ui/chart/LineChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineChart.tsx @@ -63,13 +63,13 @@ type LegendProps = { const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => { if (active && payload && payload.length) { return ( -
-

{label}

+
+

{label}

{payload.filter(item => item.value != null && String(item.value) != 'NaN' && item.value != undefined).map((item, index) => (
{/* 색상 박스 추가 (범례 색상과 동일) */}
- {` ${item.name} : ${item.value.toString().replace(',','')}`} + {` ${item.name} : ${item.value.toString().replace(',','')}`}
))}
@@ -122,7 +122,7 @@ const CustomLegend = (props: LegendProps) => { minHeight: "14px", }} >
- {entry.value} + {entry.value} ))} @@ -141,6 +141,7 @@ const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:stri textAnchor="middle" transform={`rotate(-90, ${x - padding}, ${y + height / 2})`} className="text-[12px] font-bold" + fill="var(--muted-foreground)" > {fieldText} @@ -153,6 +154,7 @@ const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:stri textAnchor="middle" transform={`rotate(90, ${x}, ${y})`} className="text-[12px] font-bold" + fill="var(--muted-foreground)" > {fieldText} @@ -277,4 +279,4 @@ export default function Linechart({ ); -} \ No newline at end of file +}