This commit is contained in:
2025-12-14 14:21:18 +09:00
17 changed files with 379 additions and 510 deletions
+1 -1
View File
@@ -9,4 +9,4 @@ NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083
# NEXT_PUBLIC_SERVER_API_URL2=http://192.168.1.222:8883/
NEXT_PUBLIC_SERVER_API_URL3=https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_SERVER_API_URL3=https://apigateway.apps.rsonesoft.com
+5 -1
View File
@@ -4,4 +4,8 @@ NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_SERVER_API_URL2=http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_SN=https://lion.hasuon.co.kr
NEXT_PUBLIC_WR=https://tiger.hasuon.co.kr
+5
View File
@@ -12,6 +12,11 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
languageOptions: {
parserOptions: {
tsconfigRootDir: __dirname,
},
},
rules: {
"react-hooks/exhaustive-deps": "off",
},
-1
View File
@@ -59,7 +59,6 @@ export function middleware(req: NextRequest) {
// 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);
+34 -2
View File
@@ -50,14 +50,41 @@ export type OperFcltStateActions = {
setOperFcltStatus: (operFcltStatus: OperFcltContent[] | null) => void;
};
export type OperFcltStore = OperFcltState & OperFcltStateActions;
const initialOperFclt: OperFcltContent[]= [
{
"cnt": 710,
"fctCpc": 2911609.0,
"rvbsn": "금강",
"gubun": "R"
},
{
"cnt": 1032,
"fctCpc": 7506047.0,
"rvbsn": "낙동강",
"gubun": "R"
},
{
"cnt": 1261,
"fctCpc": 2061610.0,
"rvbsn": "영산강",
"gubun": "R"
},
{
"cnt": 983,
"fctCpc": 1.3229809E7,
"rvbsn": "한강",
"gubun": "R"
}
];
export const defaultInitOperFcltState: OperFcltState = {
operFcltStatus: null,
operFcltStatus: initialOperFclt,
};
export const createOperFcltStore = (initState: OperFcltState = defaultInitOperFcltState) =>
createStore<OperFcltStore>()((set) => ({
...initState,
setOperFcltStatus: (operFcltStatus) => set(() => ({ operFcltStatus })),
}));
export const operFcltStore = createOperFcltStore();
//수계별 유입ㆍ방류수질 분석
export type OperWqltState = {
@@ -87,7 +114,12 @@ const initialWqltData: OperWqltContent[] = [
export const defaultInitOperWqltState: OperWqltState = {
operWqltStatus: initialWqltData,
};
export const operFcltStore = createOperFcltStore();
export const createOperWqltStore = (initState: OperWqltState = defaultInitOperWqltState) =>
createStore<OperWqltStore>()((set) => ({
...initState,
setOperWqltStatus: (operWqltStatus) => set(() => ({ operWqltStatus })),
}));
export const operWqltStore = createOperWqltStore();
// 물재이용 현황
export type ReuseState = {
+53 -14
View File
@@ -1,31 +1,69 @@
// apps/cm/src/pages/dashboard/api/sn-service.ts
import { client } from "@/shared/api";
import { createStore } from "zustand/vanilla";
import { create } from "zustand";
import { ENDPOINT } from "@/shared/config";
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
import type { OldPplChartData } 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,
const initialOldPplChartData: OldPplChartData={
labels: [
"금강",
"한강",
"낙동강",
"영산강"
],
datasets: [
{
label: "20-25년",
data: [
1140,
3181,
3100,
1662
],
backgroundColor: "#3B82F6",
borderRadius: 4
},
{
label: "25-30년",
data: [
1391,
3615,
1814,
1411
],
backgroundColor: "#EF4444",
borderRadius: 4
},
{
label: "30년이상",
data: [
2014,
10303,
9831,
2000
],
backgroundColor: "#F87171",
borderRadius: 4
}
]
};
export const createSnStore = (initState: SnState = defaultInitState) =>
createStore<SnStore>()((set) => ({
...initState,
setSnStatus: (snStatus) => set(() => ({ snStatus })),
}));
// 전역으로 공유할 store 인스턴스
// export const snStore = createSnStore();
type OldPplStoreState = {
chartData: OldPplChartData;
setChartData: (chartData: OldPplChartData) => void;
};
export const useOldPplStore = create<OldPplStoreState>((set) => ({
chartData: initialOldPplChartData,
setChartData: (chartData) => set(() => ({ chartData })),
}));
export class SewerageApiService implements SewerageService {
// 기간별 관로 총길이
@@ -43,6 +81,7 @@ export class SewerageApiService implements SewerageService {
...item,
sumPrlgVl: Number(item.sumPrlgVl),
}));
return list;
}
@@ -1,74 +1,22 @@
"use client";
/**
* 유역 지도
*/
import React, { useEffect, useRef } from "react";
import { useStore } from "zustand";
import styles from "./korea-basin-map.module.css"; // 추가
import { basinStore } from "../api/fo-service";
import { basinKeyMap,MANUAL_LABEL_POS } from "../model/map-data";
import { BASIN_DATA, BASIN_IDS } from "../model/map-data";
// import { operFcltStore } from "../api/fo-service";
// import {Button} from "@common/components";
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
// 2. 유역 키를 매핑하는 로직 (예: "한강" -> "han")
export const basinKeyMap: { [key: string]: string } = {
"한강": "han",
"금강": "geum",
"낙동강": "nakdong",
"영산강": "yeongsan",
};
// --- 라벨 좌표(수동 고정값) : inset 적용 영역 기준 퍼센트 또는 px 사용 ---
// - 퍼센트: { x:0~1, y:0~1 } (권장: 반응형에 유리)
// - 픽셀: { px:number, py:number } (inset 좌상단 원점 기준)
const MANUAL_LABEL_POS = {
han: { x: 0.52, y: 0.14 },
geum: { x: 0.28, y: 0.47 },
nakdong: { x: 0.64, y: 0.47 },
yeongsan: { x: 0.18, y: 0.77 },
};
const BASIN_DATA = {
han: {
name: "한강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
geum: {
name: "금강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
nakdong: {
name: "낙동강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
yeongsan: {
name: "영산강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
};
const BASIN_IDS = [
{ key: "han", labels: ["한강", "han"] },
{ key: "geum", labels: ["금강", "geum"] },
{ key: "nakdong", labels: ["낙동강", "nakdong"] },
{ key: "yeongsan", labels: ["영산강", "yeongsan"] },
];
function fmt(n: number) {
return new Intl.NumberFormat("ko-KR").format(n);
}
export function KoreaBasinMap() {
const stageRef = useRef<HTMLDivElement>(null);
const svgLayerRef = useRef<HTMLDivElement>(null);
@@ -76,10 +24,12 @@ export function KoreaBasinMap() {
const popupRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const rowsRef = useRef<HTMLTableSectionElement>(null);
const colTitleRef = useRef<HTMLTableCellElement>(null);
const colTitleRef = useRef<HTMLTableCellElement>(null);
// const [services, setServices] = useState(svcInfo);
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
// const { operFcltStatus, setSelectedBasin } = useStore(operFcltStore);
const selectedBasinKey = basinKeyMap[selectedBasin];
// selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect
useEffect(() => {
if (selectedBasinKey) {
@@ -102,12 +52,12 @@ export function KoreaBasinMap() {
// console.log("Loading SVG from:", SVG_URL);
fetch(SVG_URL)
.then((r) => {
console.log("SVG fetch response:", r.status, r.statusText);
// console.log("SVG fetch response:", r.status, r.statusText);
if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
return r.text();
})
.then((txt) => {
console.log("SVG content loaded, length:", txt.length);
// console.log("SVG content loaded, length:", txt.length);
if (!svgLayerRef.current) {
console.error("svgLayerRef.current is null");
return;
@@ -118,7 +68,7 @@ export function KoreaBasinMap() {
console.error("SVG element not found in loaded content");
return;
}
console.log("SVG element found:", svg);
// console.log("SVG element found:", svg);
// viewBox/크기 세팅
if (!svg.hasAttribute("viewBox")) {
@@ -138,13 +88,13 @@ export function KoreaBasinMap() {
// 1) 지도 그룹 수집
gs = [];
console.log("Looking for basin groups in SVG...");
// console.log("Looking for basin groups in SVG...");
BASIN_IDS.forEach((item) => {
let g: SVGGElement | null = null;
for (const id of item.labels) {
g = svg!.getElementById(id) as SVGGElement | null;
if (g) {
console.log(`Found group for ${item.key} with id '${id}':`, g);
// console.log(`Found group for ${item.key} with id '${id}':`, g);
break;
}
}
@@ -167,20 +117,20 @@ export function KoreaBasinMap() {
gs.push(g);
});
console.log(`Found ${gs.length} basin groups`);
// console.log(`Found ${gs.length} basin groups`);
// 각 유역 그룹의 패스 정보 확인
gs.forEach((g, index) => {
const paths = g.querySelectorAll('path');
console.log(`Group ${index} (${(g as SVGGElement & { dataset: { key: string } }).dataset.key}):`, {
element: g,
pathCount: paths.length,
paths: Array.from(paths).map(p => ({
className: p.className.baseVal,
d: p.getAttribute('d')?.substring(0, 50) + '...'
}))
});
});
// gs.forEach((g, index) => {
// const paths = g.querySelectorAll('path');
// // console.log(`Group ${index} (${(g as SVGGElement & { dataset: { key: string } }).dataset.key}):`, {
// // element: g,
// // pathCount: paths.length,
// // paths: Array.from(paths).map(p => ({
// // className: p.className.baseVal,
// // d: p.getAttribute('d')?.substring(0, 50) + '...'
// // }))
// // });
// });
// 2) 함수들 먼저 정의
const clear = () => {
@@ -204,7 +154,7 @@ export function KoreaBasinMap() {
// 유역정보
function showFor(g: SVGGElement) {
console.log("showFor called for:", g);
// console.log("showFor called for:", g);
const key = (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key;
const data = (BASIN_DATA as Record<string, { name: string; colTitle: string; rows: (string | number)[][] }>)[key] || {
name: (g as SVGGElement & { dataset: { label: string } }).dataset.label,
@@ -230,7 +180,7 @@ export function KoreaBasinMap() {
const cx = (r.left + r.right) / 2 - sRect.left;
const ty = r.top - sRect.top;
console.log("Popup position:", { cx, ty });
// console.log("Popup position:", { cx, ty });
if (badgeRef.current) {
badgeRef.current.style.left = cx + "px";
@@ -244,7 +194,7 @@ export function KoreaBasinMap() {
if (styles.show) {
popupRef.current.classList.add(styles.show);
}
console.log("Added show class to popup:", popupRef.current.classList.toString());
// console.log("Added show class to popup:", popupRef.current.classList.toString());
}
if (badgeRef.current && styles.show) {
badgeRef.current.classList.add(styles.show);
@@ -262,7 +212,7 @@ export function KoreaBasinMap() {
}
function showForWithLabel(g: SVGGElement) {
console.log("showForWithLabel called for:", g);
// console.log("showForWithLabel called for:", g);
showFor(g);
const idx = gs.indexOf(g);
labels.forEach((l) => l.classList.remove("active"));
@@ -382,81 +332,110 @@ export function KoreaBasinMap() {
};
}, []);
// const handleSvcInfoClick = (clickedSvcId: string) => {
// setServices(prevServices =>
// prevServices.map(svc => ({
// ...svc,
// active: svc.svcId === clickedSvcId,
// }))
// );
// };
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<div
ref={stageRef}
id="stage"
style={
{
aspectRatio: 0.72/1,
width: "100%",
maxHeight: "100%",
position: "relative",
margin: "auto",
"--map-inset": "0%",
} as React.CSSProperties
}
>
<div
className={styles.bgLayer}
id="bgLayer"
/>
<div
ref={svgLayerRef}
id="svgLayer"
className={`${styles.svgLayer} ${styles.svgContainer}`}
style={{ width: "100%", height: "100%" }}
/>
<div
ref={labelsLayerRef}
id="labelsLayer"
className={styles.labelsLayer}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
<div
ref={badgeRef}
id="badge"
className={styles.badge}
style={{ position: "absolute", zIndex: 10, display: "block" }}
/>
<div
ref={popupRef}
id="popup"
className={styles.popup}
style={{
position: "absolute",
zIndex: 20,
background: "#fff",
border: "1px solid #ccc",
borderRadius: 8,
padding: 12,
minWidth: 160,
boxShadow: "0 2px 8px #0002",
display: "block",
opacity: 0,
visibility: "hidden",
}}
>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>구분</th>
<th ref={colTitleRef} id="colTitle">
시설수
</th>
</tr>
</thead>
<tbody ref={rowsRef} id="rows"></tbody>
</table>
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
<div className="card card-border" style={{ height: "100%" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
{/*
{services.map((svc) => (
<Button
key={svc.name}
className="h-[3.2rem]"
color={svc.active ? "primary" : "white"}
onClick={() => handleSvcInfoClick(svc.svcId)}
>
{svc.name}
</Button>
))}
*/}
</div>
<div id="mapArea" style={{height: "calc(100% - 40px)"}}>
<div
ref={stageRef}
id="stage"
style={
{
aspectRatio: 0.72/1,
width: "100%",
maxHeight: "100%",
position: "relative",
margin: "auto",
"--map-inset": "0%",
} as React.CSSProperties
}
>
<div
className={styles.bgLayer}
id="bgLayer"
/>
<div
ref={svgLayerRef}
id="svgLayer"
className={`${styles.svgLayer} ${styles.svgContainer}`}
style={{ width: "100%", height: "100%" }}
/>
<div
ref={labelsLayerRef}
id="labelsLayer"
className={styles.labelsLayer}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
<div
ref={badgeRef}
id="badge"
className={styles.badge}
style={{ position: "absolute", zIndex: 10, display: "block" }}
/>
<div
ref={popupRef}
id="popup"
className={styles.popup}
style={{
position: "absolute",
zIndex: 20,
background: "#fff",
border: "1px solid #ccc",
borderRadius: 8,
padding: 12,
minWidth: 160,
boxShadow: "0 2px 8px #0002",
display: "block",
opacity: 0,
visibility: "hidden",
}}
>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>구분</th>
<th ref={colTitleRef} id="colTitle">
시설수
</th>
</tr>
</thead>
<tbody ref={rowsRef} id="rows"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
export const basinKeyMap: { [key: string]: string } = {
"한강": "han",
"금강": "geum",
"낙동강": "nakdong",
"영산강": "yeongsan",
};
// --- 라벨 좌표(수동 고정값) : inset 적용 영역 기준 퍼센트 또는 px 사용 ---
// - 퍼센트: { x:0~1, y:0~1 } (권장: 반응형에 유리)
// - 픽셀: { px:number, py:number } (inset 좌상단 원점 기준)
export const MANUAL_LABEL_POS = {
han: { x: 0.52, y: 0.14 },
geum: { x: 0.28, y: 0.47 },
nakdong: { x: 0.64, y: 0.47 },
yeongsan: { x: 0.18, y: 0.77 },
};
export const BASIN_DATA = {
han: {
name: "한강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
geum: {
name: "금강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
nakdong: {
name: "낙동강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
yeongsan: {
name: "영산강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
};
export const BASIN_IDS = [
{ key: "han", labels: ["한강", "han"] },
{ key: "geum", labels: ["금강", "geum"] },
{ key: "nakdong", labels: ["낙동강", "nakdong"] },
{ key: "yeongsan", labels: ["영산강", "yeongsan"] },
];
export const svcInfo = [
{svcId: "wr", active: true, name:"물재이용"},
{svcId: "sn", active: false, name:"하수관로"},
{svcId: "fo", active: false, name:"운영실태"},
]
@@ -1,3 +1,4 @@
import { basinKeyMap } from "../model/map-data";
export interface PplLengthContent {
zoneNm: string;
sumPrlgVl: number;
@@ -6,4 +7,56 @@ export interface PplLengthContent {
export interface SewerageService {
getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
}
export interface ChartDataSet {
label: string;
data: (number | undefined)[];
backgroundColor: string;
borderRadius?: number;
}
export interface OldPplChartData {
labels: string[];
datasets: ChartDataSet[];
}
type SnOldPplItem = {
name: string;
colTitle: string[];
rows: number[];
};
type SnOldPplLength = {
[key: string]: SnOldPplItem;
};
export function buildSnOldPplLength(
chartData: {
labels: string[];
datasets: { label: string; data: number[] }[];
},
totalLengths: number[]
): SnOldPplLength {
const colTitle = ["총길이", ...chartData.datasets.map((ds) => ds.label)];
const result: SnOldPplLength = {};
chartData.labels.forEach((basinName, index) => {
const key = basinKeyMap[basinName]; // geum, han, nakdong, yeongsan
if (!key) return; // 매핑 안되면 스킵
const total = totalLengths[index] ?? 0;
const datasetValues = chartData.datasets.map(
(ds) => ds.data[index] ?? 0,
);
result[key] = {
name: basinName,
colTitle,
rows: [
total, // 총길이 (undefined면 0)
...datasetValues, // 각 연차 데이터 (undefined면 0)
],
};
});
return result;
}
@@ -17,7 +17,6 @@ import {
Tooltip,
ArcElement,
} from "chart.js";
// import { Doughnut } from "react-chartjs-2";
import SnPplStatus from "./sn-ppl-status";
import SnOldPpl from "./sn-old-ppl";
import FoFcltCntRt from "./fo-fclt-cnt-rt";
@@ -139,16 +138,7 @@ function DashboardPage() {
</div>
{/* 지도영역 */}
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
<div className="card card-border" style={{ height: "100%" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
</div>
<div id="mapArea" style={{height: "calc(100% - 40px)"}}>
<KoreaBasinMap />
</div>
</div>
</div>
<KoreaBasinMap />
{/* 사용자 정보 카드 */}
<div className="card card-border col-span-1 h-80 p-10">
@@ -1,303 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { KoreaBasinMap } from "../lib/korea-basin-map";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
ArcElement,
} from "chart.js";
import { Doughnut } from "react-chartjs-2";
// Chart.js 등록
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
ArcElement
);
interface UserInfo {
email?: string;
userId?: string;
userName?: string;
twoFactorEnabled?: boolean;
}
function DashboardPage() {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [error, setError] = useState("");
const router = useRouter();
useEffect(() => {
const fetchUserInfo = async () => {
console.log("fetchUserInfo called");
try {
// tokenManager를 통해 토큰 확인
const accessToken = tokenManager.getToken("Authorization");
const userId = tokenManager.getToken("token-id");
if (!accessToken || !userId) {
console.log("No tokens found, redirecting to login");
setError("로그인이 필요합니다.");
router.push("/auth/login");
return;
}
console.log("Fetching user info with userId:", userId);
// client를 사용하여 API 호출 (자동 토큰 처리)
const response = await client.get(
`${ENDPOINT.USER_SERVICE.USERS}/${userId}`,
);
if (response.data) {
console.log("User info received:", response.data);
setUserInfo(response.data);
} else {
setError("사용자 정보를 불러올 수 없습니다.");
}
} catch (error) {
console.error("Error fetching user info:", error);
if (error instanceof Error && error.message.includes("401")) {
setError("인증이 만료되었습니다. 다시 로그인해주세요.");
tokenManager.clearAuthToken();
router.push("/auth/login");
} else {
setError("네트워크 오류가 발생했습니다.");
}
}
};
fetchUserInfo();
}, [router]);
const handleLogout = () => {
console.log("Logging out...");
// localStorage 정리
localStorage.removeItem("tempToken");
// tokenManager를 통해 쿠키 정리
tokenManager.clearAuthToken();
router.push("/auth/logout");
};
const handleChangePassword = () => {
router.push("/otp?next=change-password");
};
return (
<div className="p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 */}
<div className="grid grid-cols-8 gap-1">
{/* 첫번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 전국 하수처리시설 가동률 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">전국 하수처리시설 가동률</h3>
</div>
<div className="h-60 p-4">
<Doughnut
data={{
datasets: [{
data: [75, 25], // 75% 가동률
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
}}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
circumference: 180, // 반원
rotation: -90, // 왼쪽에서 시작
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}}
plugins={[{
id: 'centerText',
afterDraw: (chart) => {
const ctx = chart.ctx;
const centerX = chart.width / 2;
const centerY = chart.height / 2;
// 가동률 텍스트
ctx.save();
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('75%', centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '12px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 30);
ctx.restore();
}
}]}
/>
</div>
</div>
{/* 오수관로 및 우수관로 현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
</div>
<div className="h-60 p-4"></div>
</div>
{/* 하수처리수 재이용 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리수 재이용</h3>
</div>
<div className="h-60 p-4"></div>
</div>
</div>
{/* 두번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 하수처리시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리시설(개소)</h3>
</div>
<div className="grid grid-cols-2 gap-1 h-60 !p-0">
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 미만(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>3,567 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 이상(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>767 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="col-span-2" style={{width: "100%", textAlign: "right"}}>기준년월 : 2025.09 </div>
</div>
</div>
{/* 노후하수관로현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후하수관로현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 재이용시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">재이용시설(개소)</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
</div>
{/* 지도영역 */}
<div className="col-span-3 h-200 !p-0 relative z-10">
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
</div>
<KoreaBasinMap />
</div>
</div>
{/* 사용자 정보 카드 */}
<div className="card card-border col-span-1 h-80 p-10">
<h2 className="mb-6 text-2xl">사용자 정보</h2>
{error && <div className="mb-4 text-red-500">{error}</div>}
{userInfo ? (
<div className="mb-4">
<div>
<b>아이디:</b> {userInfo.userId}
</div>
<div>
<b>이메일:</b> {userInfo.email || userInfo.userId}
</div>
<div>
<b>이름:</b> {userInfo.userName || "-"}
</div>
<div>
<b>2FA 활성화:</b>{" "}
{userInfo.twoFactorEnabled ? "예" : "아니오"}
</div>
</div>
) : (
!error && <div>사용자 정보를 불러오는 중...</div>
)}
<button className="btn mb-2 w-full" onClick={handleLogout}>
로그아웃
</button>
<button className="btn w-full" onClick={handleChangePassword}>
패스워드 변경
</button>
</div>
</div>
{/* 두번째 라인 */}
<div className="grid grid-cols-4 gap-1">
<div className="card card-border"id="grouping-statistics">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">지원현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">자료관 자료현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 배너 영역 */}
<div className="card card-border">
</div>
</div>
</div>
</div>
);
}
export { DashboardPage };
@@ -51,7 +51,7 @@ const FoFcltCntRt: React.FC = () => {
operatingRate,
chartData: [operatingCapacity, nonOperatingCapacity],
};
console.log(result);
// console.log(result);
return result;
}, [operFcltStatus, selectedBasin]);
@@ -37,7 +37,7 @@ const FoWqltInflEffl: React.FC = () => {
const standardYear = await OperationApi.getStandardYear();
const data = await OperationApi.getDashOperWqlt(standardYear);
setWqltData(data);
console.log(wqltData);
// console.log(wqltData);
} catch (e) {
console.error("시설 유입ㆍ방류수질 데이터 조회 실패", e);
}
@@ -66,7 +66,7 @@ const FoWqltInflEffl: React.FC = () => {
return { labels, inflowData, effluentData, inflowAvg, effluentAvg };
}, [wqltData, selectedBasin, selectedTab]);
console.log(chartData);
// console.log(chartData);
// 차트 공통 옵션
// const getChartOptions = (yAxisLabel: string): ChartOptions<'line'> => ({
@@ -175,11 +175,15 @@ const ReuseChart: React.FC = () => {
startSlider();
};
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_WR, "_blank", "noopener,noreferrer");
};
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">{chartItems[activeIndex]?.title}</h3>
<Button className="h-[3.2rem]">더보기</Button>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="h-60 p-4 relative overflow-hidden">
<div
@@ -26,11 +26,15 @@ const ReuseStatus: React.FC = () => {
return new Intl.NumberFormat('ko-KR').format(num);
};
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_WR, "_blank", "noopener,noreferrer");
};
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">물재이용 현황 (재이용량, m³)</h3>
<Button className="h-[3.2rem]">더보기</Button>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="h-70 p-4">
<div className="w-full h-full flex items-center justify-center">
+10 -13
View File
@@ -15,15 +15,10 @@ 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";
import type { PplLengthContent, ChartDataSet } from "../model/sn-interface";
import { SewerageApi, useOldPplStore } from "../api/sn-service";
interface ChartDataSet {
label: string;
data: (number | undefined)[];
backgroundColor: string;
borderRadius?: number;
}
// ChartJS 컴포넌트 등록
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);
@@ -31,13 +26,11 @@ export default function SnOldPpl() {
const [oldPpl2025Data, setOldPpl2025Data] = useState<PplLengthContent[]>([]);
const [oldPpl2530Data, setOldPpl2530Data] = useState<PplLengthContent[]>([]);
const [oldPpl30Data, setOldPpl30Data] = useState<PplLengthContent[]>([]);
const [chartData, setChartData] = useState<{
labels: string[];
datasets: ChartDataSet[];
}>({ labels: [], datasets: [] });
const {chartData, setChartData} = useOldPplStore();
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 },
@@ -164,11 +157,15 @@ export default function SnOldPpl() {
}
};
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_SN, "_blank", "noopener,noreferrer");
};
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후 하수관로 현황</h3>
<Button className="h-[3.2rem]">더보기</Button>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="h-70 p-0 flex items-center justify-center">
<div style={{ width: "100%", maxWidth: 400, height: 131 }}>
@@ -184,14 +184,15 @@ export default function SnPplStatus() {
},
};
//const data = { labels, datasets };
const handleMoreClick = () => {
window.open(process.env.NEXT_PUBLIC_SN, "_blank", "noopener,noreferrer");
};
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
<Button className="h-[3.2rem]">더보기</Button>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="h-60 p-4">
<div style={{ width: "100%", maxWidth: 400, height: 150 }}>