From b440e50cc44e11891f209e8e1d97fc72f0744f99 Mon Sep 17 00:00:00 2001 From: ljpark Date: Wed, 10 Dec 2025 13:45:44 +0900 Subject: [PATCH] =?UTF-8?q?cm:=20=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C?= =?UTF-8?q?=ED=98=84=ED=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cm/middleware.ts | 8 +- apps/cm/src/pages/dashboard/api/fo-service.ts | 12 +- apps/cm/src/pages/dashboard/api/sn-service.ts | 69 +++++++++ .../src/pages/dashboard/model/sn-interface.ts | 9 ++ .../src/pages/dashboard/ui/reuse-status.tsx | 10 -- apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx | 118 +++++++++------ .../src/pages/dashboard/ui/sn-ppl-status.tsx | 138 ++++++++++++++---- apps/cm/src/shared/config/api.ts | 2 + 8 files changed, 284 insertions(+), 82 deletions(-) create mode 100644 apps/cm/src/pages/dashboard/api/sn-service.ts create mode 100644 apps/cm/src/pages/dashboard/model/sn-interface.ts diff --git a/apps/cm/middleware.ts b/apps/cm/middleware.ts index 3fb378cf9..181d5f613 100644 --- a/apps/cm/middleware.ts +++ b/apps/cm/middleware.ts @@ -52,11 +52,14 @@ export function middleware(req: NextRequest) { } - if (pathname.startsWith("/sewerage-service")) { + if (pathname.startsWith("/server/sewerage-service")) { const requestHeaders = new Headers(req.headers); const token = req.cookies.get(CLAIM_NAME)?.value; const userId = req.cookies.get(AUTH_USER_ID)?.value; - const newUrl = `${serverApiUrl2}${pathname}`; + + // const decodedQuery = toDecodedQueryString(searchParams); + const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}${searchParams ? `?${searchParams}` : ""}`; + // const newUrl = `${serverApiUrl2}${pathname}`; if (token) requestHeaders.set(CLAIM_NAME, token); if (userId) requestHeaders.set(AUTH_USER_ID, userId); @@ -93,7 +96,6 @@ export function middleware(req: NextRequest) { const token = req.cookies.get(CLAIM_NAME)?.value; const userId = req.cookies.get(AUTH_USER_ID)?.value; const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}`; -console.log("==========================================" + newUrl); if (token) requestHeaders.set(CLAIM_NAME, token); if (userId) requestHeaders.set(AUTH_USER_ID, userId); diff --git a/apps/cm/src/pages/dashboard/api/fo-service.ts b/apps/cm/src/pages/dashboard/api/fo-service.ts index 3468cdbb1..4c14c0d26 100644 --- a/apps/cm/src/pages/dashboard/api/fo-service.ts +++ b/apps/cm/src/pages/dashboard/api/fo-service.ts @@ -14,8 +14,14 @@ export type ReuseStateActions = { export type ReuseStore = ReuseState & ReuseStateActions; +const initialData: ReuseStatusContent[] = [ + { yr: '2021', sewage: 7335803, reclaimed: 254805510.3, rainwater: 14049224.5 }, + { yr: '2022', sewage: 7378907, reclaimed: 243457788.8, rainwater: 15128093.4 }, + { yr: '2023', sewage: 7537151, reclaimed: 222475997, rainwater: 13944732.5 }, + { yr: '2024', sewage: 7479136, reclaimed: 265625917.7, rainwater: 13305309.5 }, +]; export const defaultInitState: ReuseState = { - reuseStatus: null, + reuseStatus: initialData, }; export const createReuseStore = (initState: ReuseState = defaultInitState) => @@ -30,8 +36,10 @@ export const reuseStore = createReuseStore(); export class OperationApiService implements OperationService { // 여러 컴포넌트에서 호출해도 서버 호출은 1번만 되도록 캐싱 public async getReuseStatus(): Promise { + // API 호출 시에는 항상 최신 데이터를 가져오도록 캐싱 로직을 제거하거나 수정할 수 있습니다. + // 여기서는 기존 캐싱 로직을 유지하되, 초기 데이터가 아닌 경우에만 API를 호출하도록 합니다. const { reuseStatus, setReuseStatus } = reuseStore.getState(); - if (reuseStatus && reuseStatus.length > 0) { + if (reuseStatus && reuseStatus.length > 0 && reuseStatus !== initialData) { return reuseStatus; } diff --git a/apps/cm/src/pages/dashboard/api/sn-service.ts b/apps/cm/src/pages/dashboard/api/sn-service.ts new file mode 100644 index 000000000..6e41b0724 --- /dev/null +++ b/apps/cm/src/pages/dashboard/api/sn-service.ts @@ -0,0 +1,69 @@ +// apps/cm/src/pages/dashboard/api/sn-service.ts +import { client } from "@/shared/api"; +import { createStore } from "zustand/vanilla"; +import { ENDPOINT } from "@/shared/config"; +import type { PplLengthContent, SewerageService } from "../model/sn-interface"; + +export type SnState = { + snStatus: PplLengthContent[] | null; +}; + +export type SnStateActions = { + setSnStatus: (snStatus: PplLengthContent[] | null) => void; +}; + +export type SnStore = SnState & SnStateActions; + +export const defaultInitState: SnState = { + snStatus: null, +}; + +export const createSnStore = (initState: SnState = defaultInitState) => + createStore()((set) => ({ + ...initState, + setSnStatus: (snStatus) => set(() => ({ snStatus })), + })); + +// 전역으로 공유할 store 인스턴스 +// export const snStore = createSnStore(); + +export class SewerageApiService implements SewerageService { + // 기간별 관로 총길이 + public async getPplLength(instlYmdFrom: string, instlYmdTo: string): Promise { + const response = await client.get<{ + PplLengthList: PplLengthContent[]; + }>(ENDPOINT.SEWERAGE_SERVICE.PPL_LENGTH, { + params: { + instlYmdFrom, + instlYmdTo, + }, + }); + + const list = (response.data.PplLengthList ?? []).map((item) => ({ + ...item, + sumPrlgVl: Number(item.sumPrlgVl), + })); + return list; + } + + // 기간별 용도별 관로 총길이 : useCd - SBA004 오수관, SBA003 우수관 + public async getPplUseLength(instlYmdFrom: string, instlYmdTo: string, useCd: string): Promise { + const response = await client.get<{ + PplLengthList: PplLengthContent[]; + }>(ENDPOINT.SEWERAGE_SERVICE.PPL_USE_LENGTH, { + params: { + instlYmdFrom, + instlYmdTo, + useCd, + }, + }); + + const list = (response.data.PplLengthList ?? []).map((item) => ({ + ...item, + sumPrlgVl: Number(item.sumPrlgVl), + })); + return list; + } +} + +export const SewerageApi = new SewerageApiService(); diff --git a/apps/cm/src/pages/dashboard/model/sn-interface.ts b/apps/cm/src/pages/dashboard/model/sn-interface.ts new file mode 100644 index 000000000..f4557b921 --- /dev/null +++ b/apps/cm/src/pages/dashboard/model/sn-interface.ts @@ -0,0 +1,9 @@ +export interface PplLengthContent { + zoneNm: string; + sumPrlgVl: number; +} + +export interface SewerageService { + getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise; + getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise; +} \ No newline at end of file diff --git a/apps/cm/src/pages/dashboard/ui/reuse-status.tsx b/apps/cm/src/pages/dashboard/ui/reuse-status.tsx index a6ea4ee8c..dc55f8339 100644 --- a/apps/cm/src/pages/dashboard/ui/reuse-status.tsx +++ b/apps/cm/src/pages/dashboard/ui/reuse-status.tsx @@ -15,16 +15,6 @@ const ReuseStatus: React.FC = () => { (state) => state.reuseStatus ) as ReuseStatusContent[]; useEffect(() => { - // const fetchData = async () => { - // try { - // const data = await OperationApi.getReuseStatus(); - // setReuseStatusData(data || []); // API 응답이 null/undefined일 경우 빈 배열로 설정 - // } catch (error) { - // console.error('물재이용 현황 데이터 조회에 실패했습니다.', error); - // } - // }; - // fetchData(); - // 여러 컴포넌트에서 호출해도 실제 API는 1번만 OperationApi.getReuseStatus().catch((e) => { console.error("물재이용 현황 데이터 조회 실패", e); }); diff --git a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx index acd73d7c2..14c84a0f2 100644 --- a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx +++ b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx @@ -2,6 +2,7 @@ /** * 노후 하수관로 현황 */ +import React, { useEffect, useState } from "react"; import { Chart as ChartJS, CategoryScale, @@ -14,44 +15,84 @@ import { } from "chart.js"; import { Bar } from "react-chartjs-2"; import { Button } from "@common/components"; +import type { PplLengthContent } from "../model/sn-interface"; +import { SewerageApi } from "../api/sn-service"; +interface ChartDataSet { + label: string; + data: (number | undefined)[]; + backgroundColor: string; + borderRadius?: number; +} // ChartJS 컴포넌트 등록 -ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend -); +ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); export default function SnOldPpl() { - // 차트 데이터 - const labels = ["한강", "낙동강", "영산강", "금강"]; + const [oldPpl2025Data, setOldPpl2025Data] = useState([]); + const [oldPpl2530Data, setOldPpl2530Data] = useState([]); + const [oldPpl30Data, setOldPpl30Data] = useState([]); + const [chartData, setChartData] = useState<{ + labels: string[]; + datasets: ChartDataSet[]; + }>({ labels: [], datasets: [] }); - const datasets = [ - { - label: '20-25년', - data: [3859, 1066, 1929, 2828], - backgroundColor: '#3B82F6', // 파란색 - borderRadius: 4, - }, - { - label: '25년-30년', - data: [3153, 1420, 1204, 2877], - backgroundColor: '#EF4444', // 빨간색 - borderRadius: 4, - }, - { - label: '30년이상', - data: [9404, 1835, 1821, 8115], - backgroundColor: '#F87171', // 연한 빨간색/분홍색 - borderRadius: 4, - }, - ]; + useEffect(() => { + const currentYear = new Date().getFullYear(); + const ranges = { + "20-25년": { from: `${currentYear - 25 + 1}0101`, to: `${currentYear - 20}1231`, setter: setOldPpl2025Data }, + "25-30년": { from: `${currentYear - 30 + 1}0101`, to: `${currentYear - 25}1231`, setter: setOldPpl2530Data }, + "30년이상": { from: "19010101", to: `${currentYear - 30}1231`, setter: setOldPpl30Data }, + }; + + Object.entries(ranges).forEach(([label, range]) => { + SewerageApi.getPplLength(range.from, range.to) + .then(range.setter) + .catch((e) => console.error(`${label} 데이터 조회 실패`, e)); + }); + }, []); + + useEffect(() => { + const allData = [...oldPpl2025Data, ...oldPpl2530Data, ...oldPpl30Data]; + if (allData.length === 0) return; + + const allZones = Array.from(new Set(allData.map(d => d.zoneNm))); + + const createDataset = (label: string, data: PplLengthContent[], color: string) => { + const dataMap = new Map(data.map(item => [item.zoneNm, item.sumPrlgVl])); + console.log(dataMap); + return { + label, + data: allZones.map(zone => dataMap.get(zone) ?? 0), + backgroundColor: color, + borderRadius: 4, + }; + }; + + const newDatasets: ChartDataSet[] = []; + if (oldPpl2025Data.length > 0) { + newDatasets.push(createDataset('20-25년', oldPpl2025Data, '#3B82F6')); + } + if (oldPpl2530Data.length > 0) { + newDatasets.push(createDataset('25-30년', oldPpl2530Data, '#EF4444')); + } + if (oldPpl30Data.length > 0) { + newDatasets.push(createDataset('30년이상', oldPpl30Data, '#F87171')); + } + + setChartData({ + labels: allZones, + datasets: newDatasets, + }); + + }, [oldPpl2025Data, oldPpl2530Data, oldPpl30Data]); // 각 지역별 총 길이 계산 - const totalLengths = [44251, 33786, 11888, 14878]; + const totalLengths = chartData.labels.map((label, i) => { + return chartData.datasets.reduce((sum, dataset) => { + const value = dataset.data[i]; + return sum + (typeof value === 'number' ? value : 0); + }, 0); + }); // 차트 옵션 설정 const options: ChartOptions<"bar"> = { @@ -104,30 +145,25 @@ export default function SnOldPpl() { const ctx = chart.ctx; // 각 데이터셋의 마지막 데이터셋의 인덱스 - const lastDatasetIndex = datasets.length - 1; - console.log(lastDatasetIndex); + if (chartData.datasets.length === 0) return; + // const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1); // 텍스트 스타일 설정 ctx.font = '11px Arial'; ctx.fillStyle = '#333'; ctx.textAlign = 'center'; // 각 지역별로 총 길이 표시 - labels.forEach((_, i) => { + chartData.labels.forEach((_, i) => { const xPos = chart.scales.x ? chart.scales.x.getPixelForTick(i) : 0; const yPos = chart.chartArea.top; // 차트 영역 최상단 위에 표시 // 막대 위에 총 길이 텍스트 표시 - if (totalLengths[i]) ctx.fillText(`총 ${totalLengths[i].toLocaleString()}km`, xPos, yPos); + if (totalLengths[i]) ctx.fillText(`총 ${totalLengths[i]?.toLocaleString()}km`, xPos, yPos); }); } } }; - const data = { - labels, - datasets, - }; - return (
@@ -136,7 +172,7 @@ export default function SnOldPpl() {
- +
diff --git a/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx b/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx index d691427b8..19ff5fa2c 100644 --- a/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx +++ b/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx @@ -2,7 +2,7 @@ /** * 오수관로 및 우수관로 현황 */ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Chart as ChartJS, CategoryScale, @@ -15,37 +15,123 @@ import { } from "chart.js"; import { Bar } from "react-chartjs-2"; import { Button } from "@common/components"; +import type { PplLengthContent } from "../model/sn-interface"; +import { SewerageApi } from "../api/sn-service"; + +interface ChartDataSet { + label: string; + data: (number | undefined)[]; + backgroundColor: string; + borderColor?: string; +} ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); export default function SnPplStatus() { - const labels = ["한강", "낙동강", "영산강", "금강"]; + const [sewageData, setSewageData] = useState([]); + const [stormwaterData, setStormwaterData] = useState([]); + const [totalData, setTotalData] = useState([]); + const [chartData, setChartData] = useState<{ + labels: string[]; + datasets: ChartDataSet[]; + }>({ labels: [], datasets: [] }); - // 데이터: [기타, 오수관로, 우수관로] - const datasets = [ - { - label: "기타", - data: [15481, 6959, 1938, 1967], - backgroundColor: "rgba(255, 178, 150, 0.9)", // 연한 주황(파스텔톤) - borderColor: "rgba(255,178,150,1)", - }, - { + useEffect(() => { + const ymdFrom = "19010101"; + const ymdTo = "20241231"; + + SewerageApi.getPplUseLength(ymdFrom, ymdTo, "SBA004") + .then((apiResult) => { + // getPplUseLength의 결과(apiResult)를 + // setSewageData 함수의 인자로 넣어서 호출합니다. + // console.log(apiResult); + setSewageData(apiResult); + }) + .catch((e) => console.error("오수관 데이터 조회 실패", e)); + + SewerageApi.getPplUseLength(ymdFrom, ymdTo, "SBA003") + .then(setStormwaterData) + .catch((e) => console.error("우수관 데이터 조회 실패", e)); + + SewerageApi.getPplLength(ymdFrom, ymdTo) + .then((apiResult) => { + // getPplLength 결과(apiResult)를 + // setTotalData 함수의 인자로 넣어서 호출합니다. + // console.log(apiResult); + setTotalData(apiResult); + }) + .catch((e) => console.error("전체 관로 데이터 조회 실패", e)); + }, []); + + // 데이터가 변경될 때마다 차트를 다시 계산 + useEffect(() => { + // 오수관 또는 우수관 데이터가 없으면 차트를 그리지 않음 + if (sewageData.length === 0 && stormwaterData.length === 0) return; + + const allZones = new Set([ + ...sewageData.map(d => d.zoneNm), + ...stormwaterData.map(d => d.zoneNm), + ...totalData.map(d => d.zoneNm) + ]); + const labels = Array.from(allZones); + + const dataByZone: { [key: string]: { total: number; sewage: number; stormwater: number } } = {}; + labels.forEach(zone => {dataByZone[zone] = { total: 0, sewage: 0, stormwater: 0 }; }); + + totalData.forEach(item => { + if (!dataByZone[item.zoneNm]) { + dataByZone[item.zoneNm] = { total: 0, sewage: 0, stormwater: 0 }; + } + dataByZone[item.zoneNm]!.total = item.sumPrlgVl; + }); + sewageData.forEach(item => { + if (!dataByZone[item.zoneNm]) { + dataByZone[item.zoneNm] = { total: 0, sewage: 0, stormwater: 0 }; + } + dataByZone[item.zoneNm]!.sewage = item.sumPrlgVl; + }); + stormwaterData.forEach(item => { + if (!dataByZone[item.zoneNm]) { + dataByZone[item.zoneNm] = { total: 0, sewage: 0, stormwater: 0 }; + } + dataByZone[item.zoneNm]!.stormwater = item.sumPrlgVl; + }); + + const datasets: ChartDataSet[] = []; + + // 오수관로 데이터셋 + datasets.push({ label: "오수관로", - data: [15878, 16336, 5670, 8073], - backgroundColor: "rgba(180, 240, 190, 0.95)", // 연한 초록(파스텔톤) - borderColor: "rgba(180,240,190,1)", - }, - { + data: labels.map(zone => dataByZone[zone]?.sewage), + backgroundColor: "rgba(180, 240, 190, 0.95)", + }); + + // 우수관로 데이터셋 + datasets.push({ label: "우수관로", - data: [12892, 10491, 4280, 4838], - backgroundColor: "rgba(170, 210, 245, 0.95)", // 연한 파랑(파스텔톤) - borderColor: "rgba(170,210,245,1)", - }, - ]; + data: labels.map(zone => dataByZone[zone]?.stormwater), + backgroundColor: "rgba(170, 210, 245, 0.95)", + }); + + // 전체 데이터가 도착했다면 '기타' 데이터셋을 계산하고 추가 + if (totalData.length > 0) { + datasets.unshift({ // '기타'를 맨 아래에 쌓기 위해 unshift 사용 + label: "기타", + data: labels.map(zone => { + const zoneData = dataByZone[zone] ?? { total: 0, sewage: 0, stormwater: 0 }; + return zoneData.total - zoneData.sewage - zoneData.stormwater; + }), + backgroundColor: "rgba(255, 178, 150, 0.9)", + }); + } + + setChartData({ labels, datasets }); + + }, [sewageData, stormwaterData, totalData]); // 총합(라벨당) 계산 - const totals = labels.map((_, i) => - datasets.reduce((s, ds) => s + (ds.data[i] as number), 0), + const totals = chartData.labels.map((_, i) => + chartData.datasets.reduce((s, ds) => s + (ds.data[i] as number), 0) ); const options: ChartOptions<"bar"> = { @@ -78,7 +164,7 @@ export default function SnPplStatus() { onComplete: function (animation) { const chart = animation.chart; const ctx = chart.ctx; - const lastMeta = chart.getDatasetMeta(datasets.length - 1); + const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1); ctx.save(); ctx.fillStyle = "#111"; ctx.font = "gray bold 12px Arial"; @@ -98,7 +184,7 @@ export default function SnPplStatus() { }, }; - const data = { labels, datasets }; + //const data = { labels, datasets }; return (
@@ -108,7 +194,7 @@ export default function SnPplStatus() {
- +
diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts index 60e78c79d..d5c812576 100644 --- a/apps/cm/src/shared/config/api.ts +++ b/apps/cm/src/shared/config/api.ts @@ -70,6 +70,8 @@ export const ENDPOINT = { POST_EVALUATION: "/board-service/api/v1/pstEvl", }, SEWERAGE_SERVICE: { + PPL_LENGTH: "/sewerage-service/api/v1/ms/pplLength", + PPL_USE_LENGTH: "/sewerage-service/api/v1/ms/pplUseLength", PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts", OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts", },