dm: 수계별개소수 가동률

This commit is contained in:
ljpark
2025-12-10 17:19:02 +09:00
parent b440e50cc4
commit 75248dc48f
7 changed files with 164 additions and 25 deletions
+2 -1
View File
@@ -95,7 +95,8 @@ export function middleware(req: NextRequest) {
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.replace("/server/", "")}`;
// const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}`;
const newUrl = `${serverApiUrl2}${pathname.replace("/server/", "")}${searchParams ? `?${searchParams}` : ""}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
+94 -9
View File
@@ -2,44 +2,127 @@
import { client } from "@/shared/api";
import { createStore } from "zustand/vanilla";
import { ENDPOINT } from "@/shared/config";
import type { ReuseStatusContent, OperationService } from "../model/fo-interface";
import type { OperFcltContent, ReuseStatusContent, OperationService } from "../model/fo-interface";
export type OperFcltState = {
operFcltStatus: OperFcltContent[] | null;
};
export type OperFcltStateActions = {
setOperFcltStatus: (operFcltStatus: OperFcltContent[] | null) => void;
};
export type OperFcltStore = OperFcltState & OperFcltStateActions;
export type ReuseState = {
reuseStatus: ReuseStatusContent[] | null;
};
export type ReuseStateActions = {
setReuseStatus: (reuseStatus: ReuseStatusContent[] | null) => void;
};
export type ReuseStore = ReuseState & ReuseStateActions;
const initialData: ReuseStatusContent[] = [
export type YearState = {
year: string | null;
};
export type YearStateActions = {
setYear: (year: string | null) => void;
};
export type YearStore = YearState & YearStateActions;
export const defaultInitOperFcltState: OperFcltState = {
operFcltStatus: null,
};
export const createOperFcltStore = (initState: OperFcltState = defaultInitOperFcltState) =>
createStore<OperFcltStore>()((set) => ({
...initState,
setOperFcltStatus: (operFcltStatus) => set(() => ({ operFcltStatus })),
}));
const initialReuseStatusData: 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: initialData,
export const defaultInitReuseState: ReuseState = {
reuseStatus: initialReuseStatusData,
};
export const createReuseStore = (initState: ReuseState = defaultInitState) =>
export const createReuseStore = (initState: ReuseState = defaultInitReuseState) =>
createStore<ReuseStore>()((set) => ({
...initState,
setReuseStatus: (reuseStatus) => set(() => ({ reuseStatus })),
}));
export const defaultYearInitState: YearState = {
year: null,
};
export const createYearStore = (initState: YearState = defaultYearInitState) =>
createStore<YearStore>()((set) => ({
...initState,
setYear: (year) => set(() => ({ year })),
}));
// 전역으로 공유할 store 인스턴스
export const operFcltStore = createOperFcltStore();
// 전역으로 공유할 store 인스턴스
export const reuseStore = createReuseStore();
// 전역으로 공유할 store 인스턴스
export const yearStore = createYearStore();
export class OperationApiService implements OperationService {
public async getStandardYear(): Promise<string> {
const { year, setYear } = yearStore.getState();
if (year) {
return year;
}
const response = await client.get<{
status: number;
data: string;
count: number;
}>(ENDPOINT.OPERATION_SERVICE.MAIN_YEAR);
const newYear = response.data.data || '2024';
setYear(newYear);
return newYear;
}
// 수계별 시설운영 개소수 및 가동률
public async getDashOperCntRt(year: string): Promise<OperFcltContent[]> {
const { operFcltStatus, setOperFcltStatus } = operFcltStore.getState();
// 간단한 캐싱: 데이터가 이미 있으면 API 호출 생략
if (operFcltStatus && operFcltStatus.length > 0) {
return operFcltStatus;
}
const response = await client.get<{
status: number;
data: OperFcltContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.OPER_CNTRT, {
params: {
year,
},
});
const list = (response.data.data ?? []).map((item) => ({
...item,
cnt: Number(item.cnt),
fctCpc: Number(item.fctCpc),
}));
setOperFcltStatus(list);
return list;
}
// 여러 컴포넌트에서 호출해도 서버 호출은 1번만 되도록 캐싱
public async getReuseStatus(): Promise<ReuseStatusContent[]> {
// API 호출 시에는 항상 최신 데이터를 가져오도록 캐싱 로직을 제거하거나 수정할 수 있습니다.
// 여기서는 기존 캐싱 로직을 유지하되, 초기 데이터가 아닌 경우에만 API를 호출하도록 합니다.
const { reuseStatus, setReuseStatus } = reuseStore.getState();
if (reuseStatus && reuseStatus.length > 0 && reuseStatus !== initialData) {
if (reuseStatus && reuseStatus.length > 0 && reuseStatus !== defaultInitReuseState.reuseStatus) {
return reuseStatus;
}
@@ -58,6 +141,8 @@ export class OperationApiService implements OperationService {
setReuseStatus(list);
return list;
}
}
export const OperationApi = new OperationApiService();
@@ -5,6 +5,13 @@ export interface ReuseStatusContent {
rainwater: number;
}
export interface OperFcltContent {
rvbsn: string; // 유역
cnt: number; // 개소수
fctCpc: number; // 가동률 (용량)
gubun: string; // 구분
}
export interface OperationService {
getReuseStatus(): Promise<ReuseStatusContent[]>;
}
@@ -9,13 +9,16 @@ import {
SelectValue,
} from "@common/components";
const BasinSelect: React.FC = () => {
const [selectedBasin, setSelectedBasin] = useState<string>("한강유역");
interface BasinSelectProps {
selectedBasin: string;
onValueChange: (value: string) => void;
}
const basins = ["한강유역", "금강유역", "낙동강유역", "영산강유역"];
const BasinSelect: React.FC<BasinSelectProps> = ({ selectedBasin, onValueChange }) => {
const basins = ["한강", "금강", "낙동강", "영산강"];
return (
<Select value={selectedBasin} onValueChange={setSelectedBasin}>
<Select value={selectedBasin} onValueChange={onValueChange}>
<SelectTrigger className="w-[100px] h-8 bg-blue-500 text-white font-bold text-md border-none hover:bg-blue-600">
<SelectValue placeholder="유역 선택" />
</SelectTrigger>
@@ -2,18 +2,59 @@
/**
* 수계별 시설운영 개소수 및 가동률
*/
import React from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useStore } from "zustand";
import BasinSelect from "./basIn-select";
import { Doughnut } from "react-chartjs-2";
// import type { OperFcltContent } from "../model/fo-interface";
import { OperationApi, operFcltStore } from "../api/fo-service";
const FoFcltCntRt: React.FC = () => {
const [year, setYear] = useState<string | null>(null);
const [selectedBasin, setSelectedBasin] = useState<string>("한강");
const operFcltStatus = useStore(operFcltStore, (state) => state.operFcltStatus);
useEffect(() => {
const fetchData = async () => {
try {
const standardYear = await OperationApi.getStandardYear();
setYear(standardYear);
await OperationApi.getDashOperCntRt(standardYear);
} catch (e) {
console.error("시설운영 데이터 조회 실패", e);
}
};
fetchData();
}, []);
const displayData = useMemo(() => {
if (!operFcltStatus) {
return { facilityCount: 0, operatingRate: 0, chartData: [0, 100] };
}
const basinData = operFcltStatus.filter(d => d.rvbsn === selectedBasin);
const operatingData = basinData.find(d => d.gubun === 'R');
const nonOperatingData = basinData.find(d => d.gubun === 'A');
const facilityCount = operatingData?.cnt ?? 0;
const operatingCapacity = operatingData?.fctCpc ?? 0;
const nonOperatingCapacity = nonOperatingData?.fctCpc ?? 0;
const totalCapacity = operatingCapacity + nonOperatingCapacity;
const operatingRate = totalCapacity > 0 ? Math.round((operatingCapacity / totalCapacity) * 100) : 0;
return {
facilityCount,
operatingRate,
chartData: [operatingCapacity, nonOperatingCapacity],
};
}, [operFcltStatus, selectedBasin]);
return (
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">수계별 시설운영 개소수 및 가동률</h3>
<BasinSelect />
<BasinSelect selectedBasin={selectedBasin} onValueChange={setSelectedBasin} />
</div>
<div className="h-60 p-4">
<div className="flex gap-2 h-full">
@@ -21,11 +62,11 @@ const FoFcltCntRt: React.FC = () => {
<div className="flex-1">
<div className="card card-border h-full flex flex-col">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium mx-auto">개소수 (2024년도)</h3>
<h3 className="text-l font-medium mx-auto">개소수 ({year}년도)</h3>
</div>
<div className="flex items-center justify-center flex-1">
<div style={{ fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a" }}>
3,567 <span style={{ fontSize: "14px", color: "#000000" }}>개소</span>
{displayData.facilityCount.toLocaleString()} <span style={{ fontSize: "14px", color: "#000000" }}>개소</span>
</div>
</div>
</div>
@@ -34,14 +75,14 @@ const FoFcltCntRt: React.FC = () => {
<div className="flex-1">
<div className="card card-border h-full flex flex-col">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium mx-auto">가동률 (2024년도)</h3>
<h3 className="text-l font-medium mx-auto">가동률 ({year}년도)</h3>
</div>
<div className="flex-1 flex items-start justify-center">
<div style={{ width: "100px", height: "100px", display: "flex", alignItems: "flex-start", justifyContent: "center" }}>
<Doughnut
data={{
datasets: [{
data: [75, 25], // 75% 가동률
data: displayData.chartData,
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
@@ -77,12 +118,12 @@ const FoFcltCntRt: React.FC = () => {
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('75%', centerX, centerY + 10);
ctx.fillText(`${displayData.operatingRate}%`, centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '10px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 30);
ctx.fillText('가동률', centerX, centerY + 28);
ctx.restore();
}
}]}
@@ -112,7 +112,7 @@ export default function SnOldPpl() {
ticks: {
stepSize: 2000,
callback: function (value) {
return value + 'km';
return Number(value).toLocaleString() + 'km';
}
},
grid: {
@@ -133,7 +133,7 @@ export default function SnOldPpl() {
tooltip: {
callbacks: {
label: function (context) {
return `${context.dataset.label}: ${context.parsed.y}km`;
return `${context.dataset.label}: ${Number(context.parsed.y).toLocaleString()}km`;
}
}
},
@@ -170,7 +170,7 @@ export default function SnOldPpl() {
<h3 className="text-2xl font-medium">노후 하수관로 현황</h3>
<Button variant="outline" size="sm" className="text-white h-8 py-0 ml-auto">더보기</Button>
</div>
<div className="h-70 p-4 flex items-center justify-center">
<div className="h-70 p-0 flex items-center justify-center">
<div style={{ width: "100%", maxWidth: 400, height: 131 }}>
<Bar options={options} data={chartData} />
</div>
+2
View File
@@ -76,6 +76,8 @@ export const ENDPOINT = {
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
},
OPERATION_SERVICE: {
MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year",
OPER_CNTRT: "/operation-service/api/v1/dashBoard/main/oper/cntrt",
REUSE_STATUS: "/operation-service/api/v1/dashBoard/main/reuse/status",
},
IC_SERVICE: {