wr: 하수재이용실적 등록1

This commit is contained in:
2026-06-30 18:14:19 +09:00
parent ff33565513
commit 06f5a3cde0
14 changed files with 953 additions and 418 deletions
+2 -2
View File
@@ -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
@@ -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<FcPrfType[]>();
const [searchTxt, setSearchTxt] = useState<string>();
const [searchTxt, setSearchTxt] = useState<string>("");
const [selectedFacilityId, setSelectedFacilityId] = useState<string>("");
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [inputData, setInputData] = useState<any[]>([]);
const [isExcelOpen, setIsExcelOPen] = useState<{
isModal: boolean;
data: FcPrfType;
}>({ isModal: false, data: {} as FcPrfType });
const [inputData, setInputData] = useState<any[]>();
const [pbnstRuseFcltId, setpbnstRuseFcltId] = useState<string>();
const [cellNum, setCellNum] = useState<number>(1);
const [searchYear, setSearchYear] = useState<string>(
String(new Date().getFullYear())
);
data: PbnstPrfRegDtlType;
}>({ isModal: false, data: {} as PbnstPrfRegDtlType });
const [searchYear, setSearchYear] = useState<string>(String(new Date().getFullYear()));
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const { t } = useTranslation("base");
const useInitRef = useRef(false);
const bdstTable = useMemo<PbnstPrfmncRow[]>(
() =>
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))}
/>
</div>
<div className="col-span-3 flex flex-row space-x-2">
@@ -261,7 +290,7 @@ export default function PrfmncPrfmncReg() {
<SearchInput
className={"w-full text-2xl"}
label=""
textValue={searchTxt || ""}
textValue={searchTxt}
setText={(e) => {
setSearchTxt(e);
}}
@@ -271,12 +300,7 @@ export default function PrfmncPrfmncReg() {
<SearchFormRight className="w-3/12 mx-5 flex flex-col items-center justify-center space-x-2 ">
<div className="space-y">
<div className="flex flex-row space-x-2">
<Button
className={btnClass}
onClick={() => {
setIsReset(true);
}}
>
<Button className={btnClass} onClick={handleSearch}>
조회
</Button>
<Button
@@ -291,12 +315,11 @@ export default function PrfmncPrfmncReg() {
<Button
className={btnClass}
onClick={() => {
if (cellNum === undefined) {
if (!selectedFacilityId) {
alert("하수처리재이용시설을 선택해주세요.");
return;
} else {
setIsExcelOPen({ ...isExcelOpen, isModal: true });
}
setIsExcelOPen((prev) => ({ ...prev, isModal: true }));
}}
disabled={regAuthrtYn === "N"}
>
@@ -318,30 +341,29 @@ export default function PrfmncPrfmncReg() {
<ScrollArea className="h-[60vh]">
<UiTable
key={"leftTable"}
tableData={bdstTable || []}
tableData={bdstTable}
headlist={pbnstPrfmncHeadList}
pageSize={10}
total={prfmncResCount}
headName={"하수재이용시설 목록"}
cellClick={(index: number | undefined) => {
cellClick(index);
setCellNum(index ?? 0);
if (index === undefined) return;
const selected = prfmncRes[index];
if (!selected) return;
setSelectedIndex(index);
void loadDetail(selected.pbnstRuseFcltId);
}}
onPageChange={(page: number) => {
if (searchTxt && searchTxt?.length > 0) {
prfmncHasuNmSearch(
page,
laeSeCd,
searchYear,
searchTxt,
prfPbnFclts
);
if (searchTxt.trim().length > 0) {
void prfmncHasuNmSearch(page, laeSeCd, searchTxt);
} else {
prfmncHasuInit(page, laeSeCd, searchYear, prfPbnFclts);
void prfmncHasuInit(page, laeSeCd);
}
setIsReset(false);
}}
resetPageNo={isReset}
index={selectedIndex}
/>
</ScrollArea>
</ListDiv>
@@ -353,19 +375,21 @@ export default function PrfmncPrfmncReg() {
{"하수처리수재이용 실적(m³) 등록결과"}
</h3>
</div>
<Table variant="secondary">
<TableHeader className="font-bold">
{headMakeprfmnc(monthHeadList, [])}
</TableHeader>
<TableBody key={prfmnc.pageKey + "pageKey"}>
{pbnstTableBody(
inputData ?? [],
monthHeadList,
setInputData,
mdfcnAuthrtYn === "N" ? false : true
)}
</TableBody>
</Table>
<div className="card !m-[1.2rem]">
<Table variant="secondary">
<TableHeader className="font-bold">
{headMakeprfmnc(monthHeadList, [])}
</TableHeader>
<TableBody key={prfmnc.pageKey + "pageKey"}>
{pbnstTableBody(
inputData,
monthHeadList,
setInputData,
mdfcnAuthrtYn === "N" ? false : true
)}
</TableBody>
</Table>
</div>
</ScrollArea>
</ListDiv>
</div>
@@ -376,22 +400,20 @@ export default function PrfmncPrfmncReg() {
<PbnPrfExcelModal
data={isExcelOpen.data}
setIsExcelOPen={(open) => {
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가 감지하여 처리하므로 여기서는 호출하지 않습니다.
}}
></PbnPrfExcelModal>
}
open={isExcelOpen.isModal}
onOpenChange={() => {
setIsExcelOPen({ ...isExcelOpen, isModal: false });
setIsExcelOPen((prev) => ({ ...prev, isModal: false }));
}}
></UiModal>
</>
);
}
@@ -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<string>;
getPrfFmcRes: () => void;
prfmncHasuInit: (page: number, sggOrCity: string) => void;
prfmncHasuNmSearch: (page: number, sggOrCity: string, searchTxt: string) => void;
prfmnRegData: (data: PrfType[], year: string, pbnstRuseFcltId: string) => Promise<string>;
getPbnstPrfRegDtl: (year: string, pbnstRuseFcltId: string) => Promise<PbnstPrfRegDtlType | undefined>;
};
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,
},
@@ -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<typeof FcPrfSchema>;
export type PrfType = z.infer<typeof PrfSchema>;
export type PbnstPrfRegListType = z.infer<typeof PbnstPrfRegListSchema>;
export type PbnstPrfRegDtlType = z.infer<typeof PbnstPrfRegDtlSchema>;
export const initValInUsage:PrfUsageSummary[] = [
{
@@ -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<File | null>(null);
const [excelData, setExcelData] = useState<PrfType[]>();
const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<z.infer<typeof pbnPrfRegExcelZod>>({
resolver: zodResolver(pbnPrfRegExcelZod),
defaultValues: {
@@ -44,26 +45,32 @@ export default function PbnPrfExcelModal({
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setExcelData(undefined);
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
}
};
const onSubmit = (values: z.infer<typeof pbnPrfRegExcelZod>) => {
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({
<div className="space-y-4 p-4">
<Input
ref={fileInputRef}
type="file"
onChange={(file) => {
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"
/>
@@ -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` 응답 구조가 바뀌면 좌우 화면이 함께 영향을 받는다.
@@ -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 책임을 분리해 화면 구조와 호출 구조를 단순화하는 것이다.
+6 -1
View File
@@ -125,7 +125,12 @@
background: #1a1c20;
}
input[type="file"] {
background-color: #1a1a1a;
border-color: #262626;
color: #f1f1f1;
}
}
input[type="file"] {
@@ -53,13 +53,13 @@ const CustomTooltip = ({
}) => {
if (active && payload && payload.length) {
return (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
{payload.map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${fncNumberFormet(item.value)}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${fncNumberFormet(item.value)}`}</span>
</div>
))}
</div>
@@ -76,7 +76,7 @@ const CustomLegend = (props: any) => {
{payload.map((entry: unknown, index: number) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: (entry as {color: string}).color }}></div>
<span className="text-[12px]">{(entry as {value: string}).value}</span>
<span className="text-[12px] text-card-foreground">{(entry as {value: string}).value}</span>
</div>
))}
</div>
@@ -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='
</div>
</>
)
}
}
@@ -44,13 +44,13 @@ const CustomTooltip = ({
}) => {
if (active && payload && payload.length) {
return (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
{payload.map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value}`}</span>
</div>
))}
</div>
@@ -67,7 +67,7 @@ const CustomLegend = (props: any) => {
{payload.map((entry: unknown, index: number) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: (entry as {color: string}).color }}></div>
<span className="text-[12px]">{(entry as {value: string}).value}</span>
<span className="text-[12px] text-card-foreground">{(entry as {value: string}).value}</span>
</div>
))}
</div>
@@ -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
</div>
</>
)
}
}
@@ -69,7 +69,7 @@ const renderCustomizedLabel = ({
<text
x={x}
y={y}
fill="#333"
fill="var(--muted-foreground)"
fontSize={11}
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
@@ -87,12 +87,12 @@ const CustomTooltip = ({ active, payload, label }: {
}) => {
if (active && payload && payload.length) {
return (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
{payload.map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ width: "10px", height: "10px", backgroundColor: item.payload.fill, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
</div>
))}
</div>
@@ -126,7 +126,7 @@ const CustomLegend = (props: LegendProps) => {
minHeight: "14px",
}}
></div>
<span style={{ fontSize: "13px", whiteSpace: "nowrap" }}>
<span className="text-card-foreground" style={{ fontSize: "13px", whiteSpace: "nowrap" }}>
{entry.payload?.label || entry.value}
</span>
</div>
@@ -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 (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
{payload.map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
</div>
))}
</div>
@@ -74,7 +74,7 @@ const CustomLegend = (props: LegendProps) => {
{payload.map((entry: LegendEntry, index: number) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: entry.color }}></div>
<span className="text-[12px]">{entry.value}</span>
<span className="text-[12px] text-card-foreground">{entry.value}</span>
</div>
))}
</div>
@@ -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}
</text>
);
@@ -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}
</text>
);
@@ -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({
</div>
</div>
);
}
}
@@ -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 (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
{payload.map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
</div>
))}
</div>
@@ -71,7 +71,7 @@ const CustomLegend = (props: LegendProps) => {
{payload.map((entry: LegendEntry, index: number) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: entry.color }}></div>
<span className="text-[12px]">{entry.value}</span>
<span className="text-[12px] text-card-foreground">{entry.value}</span>
</div>
))}
</div>
@@ -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({
</div>
</div>
);
}
}
@@ -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 (
<div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="text-[12px] font-bold m-3">{label}</p>
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
{payload.filter(item => item.value != null && String(item.value) != 'NaN' && item.value != undefined).map((item, index) => (
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
{/* 색상 박스 추가 (범례 색상과 동일) */}
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
<span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(',','')}`}</span>
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(',','')}`}</span>
</div>
))}
</div>
@@ -122,7 +122,7 @@ const CustomLegend = (props: LegendProps) => {
minHeight: "14px",
}}
></div>
<span style={{ fontSize: "13px", whiteSpace: "nowrap" }}>{entry.value}</span>
<span className="text-card-foreground" style={{ fontSize: "13px", whiteSpace: "nowrap" }}>{entry.value}</span>
</div>
))}
</div>
@@ -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}
</text>
@@ -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}
</text>
@@ -277,4 +279,4 @@ export default function Linechart({
</div>
</div>
);
}
}