diff --git a/.gitignore b/.gitignore index 3fea45bd1..f167b61e8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ apps/fo/.next/**/* #.env* .qodo -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +settings.json \ No newline at end of file diff --git a/apps/cm/.env.local b/apps/cm/.env.local index 1ef6a3045..da63ebdfd 100644 --- a/apps/cm/.env.local +++ b/apps/cm/.env.local @@ -7,4 +7,5 @@ NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000 NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014 NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 -NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083/ \ No newline at end of file +# NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083/ +NEXT_PUBLIC_SERVER_API_URL2=http://192.168.1.222:8883/ \ No newline at end of file diff --git a/apps/cm/middleware.ts b/apps/cm/middleware.ts index ac1e5464b..9ec06daa2 100644 --- a/apps/cm/middleware.ts +++ b/apps/cm/middleware.ts @@ -14,11 +14,11 @@ const serverApiUrl = const serverApiUrl2 = process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl; - const serverApiUrl3 = - process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl; + // const serverApiUrl3 = + // process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl; - const serverApiUrl4 = - process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl; + // const serverApiUrl4 = + // process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl; const serverApiUrl5 = process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl; @@ -33,19 +33,6 @@ export function middleware(req: NextRequest) { `[Request]\n Original Pathname: ${pathname}`, ]; - if (pathname.startsWith("/server")) { - const queryParams = searchParams.toString(); - const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ - queryParams ? `?${queryParams}` : "" - }`; - - logMessages.push(` Rewritten URL: ${newUrl}`); - logMessages.push("\n--------------------------------\n"); - console.log(logMessages.join("\n")); - - return NextResponse.rewrite(new URL(newUrl)); - } - if (pathname.startsWith("/attach-service")) { const requestHeaders = new Headers(req.headers); const token = req.cookies.get(CLAIM_NAME)?.value; @@ -57,7 +44,7 @@ export function middleware(req: NextRequest) { logMessages.push(` Rewritten URL: ${newUrl}`); logMessages.push("\n--------------------------------\n"); - console.log(logMessages.join("\n")); + // console.log(logMessages.join("\n")); return NextResponse.rewrite(new URL(newUrl), { headers: requestHeaders, @@ -65,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); @@ -87,7 +77,7 @@ 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 = `${serverApiUrl3}${pathname}`; + const newUrl = `${serverApiUrl2}${pathname}`; if (token) requestHeaders.set(CLAIM_NAME, token); if (userId) requestHeaders.set(AUTH_USER_ID, userId); @@ -101,11 +91,12 @@ export function middleware(req: NextRequest) { }); } - if (pathname.startsWith("/operation-service")) { + if (pathname.startsWith("/server/operation-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 = `${serverApiUrl4}${pathname}`; + // 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); @@ -163,11 +154,25 @@ export function middleware(req: NextRequest) { logMessages.push(` User Service Rewritten URL: ${newUrl}`); logMessages.push("\n--------------------------------\n"); - console.log(logMessages.join("\n")); + // console.log(logMessages.join("\n")); return NextResponse.rewrite(new URL(newUrl)); } + if (pathname.startsWith("/server")) { + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + // console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl)); + } + + if (pathname === "/") { return NextResponse.redirect(new URL("/dashboard", req.url)); } @@ -178,5 +183,11 @@ export function middleware(req: NextRequest) { } export const config = { - matcher: ["/server/:path*", "/attach-service/:path*", "/user-service/:path*", "/"], + matcher: ["/server/:path*", + "/attach-service/:path*", + "/user-service/:path*", + "/sewerage-service/:path*", + "/reuse-service/:path*", + "/operation-service/:path*", + "/"], }; diff --git a/apps/cm/src/pages/dashboard/api/fo-service.ts b/apps/cm/src/pages/dashboard/api/fo-service.ts new file mode 100644 index 000000000..2e0f6e7ae --- /dev/null +++ b/apps/cm/src/pages/dashboard/api/fo-service.ts @@ -0,0 +1,148 @@ +// apps/cm/src/pages/dashboard/api/fo-service.ts +import { client } from "@/shared/api"; +import { createStore } from "zustand/vanilla"; +import { ENDPOINT } from "@/shared/config"; +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; + +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()((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 defaultInitReuseState: ReuseState = { + reuseStatus: initialReuseStatusData, +}; +export const createReuseStore = (initState: ReuseState = defaultInitReuseState) => + createStore()((set) => ({ + ...initState, + setReuseStatus: (reuseStatus) => set(() => ({ reuseStatus })), + })); + +export const defaultYearInitState: YearState = { + year: null, +}; +export const createYearStore = (initState: YearState = defaultYearInitState) => + createStore()((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 { + 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 { + 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 { + // API 호출 시에는 항상 최신 데이터를 가져오도록 캐싱 로직을 제거하거나 수정할 수 있습니다. + // 여기서는 기존 캐싱 로직을 유지하되, 초기 데이터가 아닌 경우에만 API를 호출하도록 합니다. + const { reuseStatus, setReuseStatus } = reuseStore.getState(); + if (reuseStatus && reuseStatus.length > 0 && reuseStatus !== defaultInitReuseState.reuseStatus) { + return reuseStatus; + } + + const response = await client.get<{ + status: number; + data: ReuseStatusContent[]; + count: number; + }>(ENDPOINT.OPERATION_SERVICE.REUSE_STATUS); + + const list = (response.data.data ?? []).map((item) => ({ + ...item, + reclaimed: Number(item.reclaimed), + rainwater: Number(item.rainwater), + })); + + setReuseStatus(list); + return list; + } + + +} + +export const OperationApi = new OperationApiService(); 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/lib/KoreaBasinMap.module.css b/apps/cm/src/pages/dashboard/lib/korea-basin-map.module.css similarity index 100% rename from apps/cm/src/pages/dashboard/lib/KoreaBasinMap.module.css rename to apps/cm/src/pages/dashboard/lib/korea-basin-map.module.css diff --git a/apps/cm/src/pages/dashboard/lib/KoreaBasinMap.tsx b/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx similarity index 99% rename from apps/cm/src/pages/dashboard/lib/KoreaBasinMap.tsx rename to apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx index 139f9f872..637f88e9d 100644 --- a/apps/cm/src/pages/dashboard/lib/KoreaBasinMap.tsx +++ b/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from "react"; -import styles from "./KoreaBasinMap.module.css"; // 추가 +import styles from "./korea-basin-map.module.css"; // 추가 const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요 diff --git a/apps/cm/src/pages/dashboard/model/fo-interface.ts b/apps/cm/src/pages/dashboard/model/fo-interface.ts new file mode 100644 index 000000000..6d57ae6bd --- /dev/null +++ b/apps/cm/src/pages/dashboard/model/fo-interface.ts @@ -0,0 +1,17 @@ +export interface ReuseStatusContent { + yr: string; + sewage: number; + reclaimed: number; + rainwater: number; +} + +export interface OperFcltContent { + rvbsn: string; // 유역 + cnt: number; // 개소수 + fctCpc: number; // 가동률 (용량) + gubun: string; // 구분 +} + +export interface OperationService { + getReuseStatus(): Promise; +} \ No newline at end of file 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/basIn-select.tsx b/apps/cm/src/pages/dashboard/ui/basIn-select.tsx new file mode 100644 index 000000000..ca077da20 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/basIn-select.tsx @@ -0,0 +1,36 @@ +"use client"; + +import React, { useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +interface BasinSelectProps { + selectedBasin: string; + onValueChange: (value: string) => void; +} + +const BasinSelect: React.FC = ({ selectedBasin, onValueChange }) => { + const basins = ["한강", "금강", "낙동강", "영산강"]; + + return ( + + ); +}; + +export default BasinSelect; \ No newline at end of file diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx index 76b6876fb..d0cc508b6 100644 --- a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -4,7 +4,7 @@ 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/KoreaBasinMap"; +import { KoreaBasinMap } from "../lib/korea-basin-map"; import { Chart as ChartJS, CategoryScale, @@ -17,7 +17,13 @@ import { Tooltip, ArcElement, } from "chart.js"; -import { Doughnut } from "react-chartjs-2"; +// 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"; +import FoWqltInflEffl from "./fo-wqlt-Infl-effl"; +import ReuseChart from "./reuse-chart"; +import ReuseStatus from "./reuse-status"; // Chart.js 등록 ChartJS.register( @@ -108,121 +114,28 @@ function DashboardPage() {
{/* 첫번쨰 열 */}
- {/* 전국 하수처리시설 가동률 */} -
-
-

전국 하수처리시설 가동률

-
-
- { - 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(); - } - }]} - /> -
-
+ {/* 전국 하수처리시설 개소수 및 가동률 */} + {/* 오수관로 및 우수관로 현황 */} -
-
-

오수관로 및 우수관로 현황

-
-
-
+ - {/* 하수처리수 재이용 */} -
-
-

하수처리수 재이용

-
-
-
+ {/* 하수처리수 재이용 (m³) */} +
{/* 두번쨰 열 */}
- {/* 하수처리시설(개소) */} -
-
-

하수처리시설(개소)

-
-
-
-
-

500㎥ 미만(개소)

-
-
3,567 개소
-
-
-
-

500㎥ 이상(개소)

-
-
767 개소
-
-
기준년월 : 2025.09
-
-
- - {/* 노후하수관로현황 */} -
-
-

노후하수관로현황

-
-
-
-
+ {/* 수계별 유입ㆍ방류수질 분석 */} + - {/* 재이용시설(개소) */} -
-
-

재이용시설(개소)

-
-
-
-
+ {/* 노후하수관로현황 */} + + + {/* 물재이용 현황 (재이용량, m³) */} +
- + {/* 지도영역 */}
@@ -267,12 +180,12 @@ function DashboardPage() { {/* 두번째 라인 */}
-
+

지원현황

- +
diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page2.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page2.tsx new file mode 100644 index 000000000..0bcce9a6a --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page2.tsx @@ -0,0 +1,303 @@ +"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(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 ( +
+
+ {/* 첫번째 라인 */} +
+ {/* 첫번쨰 열 */} +
+ {/* 전국 하수처리시설 가동률 */} +
+
+

전국 하수처리시설 가동률

+
+
+ { + 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(); + } + }]} + /> +
+
+ + {/* 오수관로 및 우수관로 현황 */} +
+
+

오수관로 및 우수관로 현황

+
+
+
+ + {/* 하수처리수 재이용 */} +
+
+

하수처리수 재이용

+
+
+
+
+ + {/* 두번쨰 열 */} +
+ {/* 하수처리시설(개소) */} +
+
+

하수처리시설(개소)

+
+
+
+
+

500㎥ 미만(개소)

+
+
3,567 개소
+
+
+
+

500㎥ 이상(개소)

+
+
767 개소
+
+
기준년월 : 2025.09
+
+
+ + {/* 노후하수관로현황 */} +
+
+

노후하수관로현황

+
+
+
+
+ + {/* 재이용시설(개소) */} +
+
+

재이용시설(개소)

+
+
+
+
+
+ + {/* 지도영역 */} +
+
+
+

유역별 정보

+
+ +
+
+ + {/* 사용자 정보 카드 */} +
+

사용자 정보

+ {error &&
{error}
} + {userInfo ? ( +
+
+ 아이디: {userInfo.userId} +
+
+ 이메일: {userInfo.email || userInfo.userId} +
+
+ 이름: {userInfo.userName || "-"} +
+
+ 2FA 활성화:{" "} + {userInfo.twoFactorEnabled ? "예" : "아니오"} +
+
+ ) : ( + !error &&
사용자 정보를 불러오는 중...
+ )} + + +
+
+ + {/* 두번째 라인 */} +
+
+
+

지원현황

+
+
+ +
+
+
+
+

자료관 자료현황

+
+
+
+
+
+
+

공지사항

+
+
+
+
+ + {/* 배너 영역 */} +
+ +
+
+
+
+ ); +} + +export { DashboardPage }; diff --git a/apps/cm/src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx b/apps/cm/src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx new file mode 100644 index 000000000..05f39cca1 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx @@ -0,0 +1,142 @@ +"use client"; +/** + * 수계별 시설운영 개소수 및 가동률 + */ +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(null); + const [selectedBasin, setSelectedBasin] = useState("한강"); + 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 ( +
+
+

수계별 시설운영 개소수 및 가동률

+ +
+
+
+ {/* 왼쪽: 개소수 카드 */} +
+
+
+

개소수 ({year}년도)

+
+
+
+ {displayData.facilityCount.toLocaleString()} 개소 +
+
+
+
+ {/* 오른쪽: Doughnut 차트 */} +
+
+
+

가동률 ({year}년도)

+
+
+
+ { + const ctx = chart.ctx; + const centerX = chart.width / 2; + const centerY = chart.height / 2; + + // 가동률 텍스트 + ctx.save(); + ctx.font = 'bold 16px Arial'; + ctx.fillStyle = '#00DFE5'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(`${displayData.operatingRate}%`, centerX, centerY + 10); + + // 라벨 텍스트 + ctx.font = '10px Arial'; + ctx.fillStyle = '#666'; + ctx.fillText('가동률', centerX, centerY + 28); + ctx.restore(); + } + }]} + /> +
+
+
+
+
+
+
+ ); + +}; + +export default FoFcltCntRt; \ No newline at end of file diff --git a/apps/cm/src/pages/dashboard/ui/fo-wqlt-Infl-effl.tsx b/apps/cm/src/pages/dashboard/ui/fo-wqlt-Infl-effl.tsx new file mode 100644 index 000000000..3320a1a87 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/fo-wqlt-Infl-effl.tsx @@ -0,0 +1,229 @@ +"use client"; +/** + * 수계별 유입ㆍ방류수질 분석 + */ +import React, { useState } from 'react'; +import { Line } from 'react-chartjs-2'; +import BasinSelect from "./basIn-select"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + type ChartOptions, +} from 'chart.js'; + +// Chart.js 등록 +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend +); + +type WaterQualityType = 'BOD' | 'TOC' | 'SS' | 'TP' | 'TN'; + +interface ChartData { + labels: string[]; + inflowData: number[]; + inflowAverage: number[]; + effluentData: number[]; + effluentAverage: number[]; +} + +const FoWqltInflEffl: React.FC = () => { + const [selectedTab, setSelectedTab] = useState('BOD'); + + // 데이터 (실제로는 API에서 가져올 데이터) + const bodData: ChartData = { + labels: ['2020', '2021', '2022', '2023', '2024'], + inflowData: [2255.6, 2282.2, 2304.3, 5156.6, 4620.4], + inflowAverage: [2255.6, 2282.2, 2304.3, 2304.3, 2304.3], + effluentData: [55, 47.5, 52, 79.7, 83.9], + effluentAverage: [55, 47.5, 52, 52, 52], + }; + + const tabs: WaterQualityType[] = ['BOD', 'TOC', 'SS', 'TP', 'TN']; + + // 차트 공통 옵션 + const getChartOptions = (yAxisLabel: string): ChartOptions<'line'> => ({ + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + legend: { + display: true, + position: 'bottom', + labels: { + usePointStyle: true, + boxWidth: 6, + color: '#000', + font: { + size: 8, + }, + }, + }, + tooltip: { + enabled: true, + titleFont: { size: 10 }, + bodyFont: { size: 10 }, + }, + }, + scales: { + x: { + grid: { + color: 'rgba(255, 255, 255, 0.1)', + tickLength: 4, // 눈금 길이 축소 + }, + ticks: { + color: '#000', + font: { + size: 8, // 폰트 크기 축소 + }, + padding: 0, + }, + }, + y: { + grid: { + color: 'rgba(255, 255, 255, 0.1)', + }, + ticks: { + color: '#000', + font: { + size: 8, // 폰트 크기 축소 + }, + maxTicksLimit: 4, // 눈금 개수 제한 + padding: 2, + }, + title: { + display: false, + }, + }, + }, + layout: { + padding: { + top: 5, + bottom: 0, + left: 0, + right: 5 + } + } + }); + + // 유입 데이터 차트 설정 + const inflowChartData = { + labels: bodData.labels, + datasets: [ + { + label: '유입BOD (mg/L)', + data: bodData.inflowData, + borderColor: '#00D4FF', + backgroundColor: '#00D4FF', + borderWidth: 1, + pointRadius: 2, + pointHoverRadius: 4, + tension: 0.4, + }, + { + label: '유입 평균 (mg/L)', + data: bodData.inflowAverage, + borderColor: '#FF1493', + backgroundColor: '#FF1493', + borderWidth: 1, + borderDash: [10, 5], + pointRadius: 2, + pointHoverRadius: 4, + tension: 0, + }, + ], + }; + + // 발류 데이터 차트 설정 + const effluentChartData = { + labels: bodData.labels, + datasets: [ + { + label: '발류BOD (mg/L)', + data: bodData.effluentData, + borderColor: '#00FF88', + backgroundColor: '#00FF88', + borderWidth: 1, + pointRadius: 2, + pointHoverRadius: 4, + tension: 0.4, + }, + { + label: '발류 평균 (mg/L)', + data: bodData.effluentAverage, + borderColor: '#FFB800', + backgroundColor: '#FFB800', + borderWidth: 1, + borderDash: [10, 5], + pointRadius: 1, + pointHoverRadius: 4, + tension: 0, + }, + ], + }; + + return ( +
+
+

수계별 유입ㆍ방류수질 분석

+ +
+
+
+ {/* 탭 메뉴 */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* 차트 영역 */} +
+ {/* 유입 차트 */} +
+ +
+ + {/* 발류 차트 */} +
+ +
+
+
+
+
+ + + ); +}; + +export default FoWqltInflEffl; diff --git a/apps/cm/src/pages/dashboard/ui/reuse-chart.tsx b/apps/cm/src/pages/dashboard/ui/reuse-chart.tsx new file mode 100644 index 000000000..0a9879c19 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/reuse-chart.tsx @@ -0,0 +1,213 @@ +"use client"; +/** + * 물재이용 차트 + */ +import React, { useState, useEffect, useRef } from 'react'; +import { useStore } from "zustand"; +import { Line } from 'react-chartjs-2'; +import { Button } from "@common/components"; +import type { ReuseStatusContent } from "../model/fo-interface"; +import { OperationApi, reuseStore } from "../api/fo-service"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, + type ChartOptions +} from 'chart.js'; + +// Chart.js 플러그인 등록 +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler // 영역 채우기를 위해 필요 +); + + + +const ReuseChart: React.FC = () => { + const [activeIndex, setActiveIndex] = useState(0); + const intervalRef = useRef(null); + const reuseStatusData = useStore( + reuseStore, + (state) => state.reuseStatus) as ReuseStatusContent[]; + + useEffect(() => { + OperationApi.getReuseStatus().catch((e) => { + console.error("물재이용 차트 데이터 조회 실패", e); + }); + }, []); + + const labels = reuseStatusData?.map((r) => r.yr); + const sewageData = reuseStatusData?.map((r) => r.sewage); + const reclaimedData = reuseStatusData?.map((r) => r.reclaimed); + const rainwaterData = reuseStatusData?.map((r) => r.rainwater); + + const commonChartData = { + datasets: [ + { + label: '재이용량', + data: sewageData, // 기본은 하수처리수 + borderColor: '#00D4FF', // 선 색상 (밝은 하늘색) + backgroundColor: 'rgba(0, 212, 255, 0.1)', // 채우기 색상 (투명도 있는 하늘색) + borderWidth: 3, + pointBackgroundColor: '#00D4FF', + pointBorderColor: '#fff', + pointBorderWidth: 2, + pointRadius: 6, + pointHoverRadius: 8, + fill: true, // 아래 영역 채우기 활성화 + tension: 0.4, // 곡선 부드럽게 + }, + ], + }; + + const commonChartOptions: ChartOptions<'line'> = { + maintainAspectRatio: false, + plugins: { + legend: { + display: false, // 범례 숨김 (이미지에 없음) + }, + tooltip: { + callbacks: { + label: (context) => { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + if (context.parsed.y !== null) { + label += new Intl.NumberFormat('ko-KR').format(context.parsed.y) + '㎥'; + } + return label; + } + } + } + }, + scales: { + x: { + grid: { + display: true, + color: '#f0f0f0', // 연한 회색 그리드 + }, + ticks: { + color: '#666', + } + }, + y: { + min: 1100000, // Y축 최소값 설정 (그래프 모양을 위해) + grid: { + color: '#f0f0f0', + }, + ticks: { + color: '#666', + callback: function(value) { + return new Intl.NumberFormat('ko-KR').format(Number(value)) + '㎥'; + } + } + } + }, + interaction: { + mode: 'index', + intersect: false, + }, + }; + + const chartItems = [ + { + title: "하수처리수 재이용량 (m³)", + data: { + ...commonChartData, + labels, + datasets: [{ ...commonChartData.datasets[0], data: sewageData }], + }, + options: commonChartOptions, + }, + { + title: "중수도 재이용량 (m³)", + data: { + ...commonChartData, + labels, + datasets: [{ ...commonChartData.datasets[0], data: reclaimedData }], + }, + options: commonChartOptions, + }, + { + title: "빗물 재이용량 (m³)", + data: { + ...commonChartData, + labels, + datasets: [{ ...commonChartData.datasets[0], data: rainwaterData }], + }, + options: commonChartOptions, + }, + ]; + + const startSlider = () => { + intervalRef.current = setInterval(() => { + setActiveIndex(prevIndex => (prevIndex + 1) % chartItems.length); + }, 20000); + }; + + const stopSlider = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + + useEffect(() => { + startSlider(); + return () => stopSlider(); + }, []); + + const handleIndicatorClick = (index: number) => { + stopSlider(); + setActiveIndex(index); + startSlider(); + }; + + return ( +
+
+

{chartItems[activeIndex]?.title}

+ +
+
+
+ {chartItems.map((chart, index) => ( +
+ } /> +
+ ))} +
+ +
+ {chartItems.map((_, index) => ( +
+
+
+ ); +}; + +export default ReuseChart; \ 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 new file mode 100644 index 000000000..dc55f8339 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/reuse-status.tsx @@ -0,0 +1,81 @@ +"use client"; +/** + * 물재이용 현황 + */ +import React, { useEffect } from 'react'; +import { useStore } from "zustand"; +import { Button } from "@common/components"; +import type { ReuseStatusContent } from "../model/fo-interface"; +import { OperationApi, reuseStore} from "../api/fo-service"; + +const ReuseStatus: React.FC = () => { + // const [reuseStatusData, setReuseStatusData] = useState([]); + const reuseStatusData = useStore( + reuseStore, + (state) => state.reuseStatus ) as ReuseStatusContent[]; + + useEffect(() => { + OperationApi.getReuseStatus().catch((e) => { + console.error("물재이용 현황 데이터 조회 실패", e); + }); + }, []); + + + // 숫자 포맷 함수 (천단위 쉼표) + const formatNumber = (num: number): string => { + return new Intl.NumberFormat('ko-KR').format(num); + }; + + return ( +
+
+

물재이용 현황 (재이용량, m³)

+ +
+
+
+ + + + + + + + + + + {reuseStatusData && reuseStatusData.length > 0 && reuseStatusData?.map((row, index) => ( + + + + + + + ))} + +
+ 년도 + + 하수재이용량 + + 중수도이용량 + + 빗물이용량 +
+ {row.yr} + + {formatNumber(row.sewage)} + + {formatNumber(row.reclaimed)} + + {formatNumber(row.rainwater)} +
+
+
+
+ + + ); +}; + +export default ReuseStatus; \ No newline at end of file diff --git a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx new file mode 100644 index 000000000..5bb36ffe6 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx @@ -0,0 +1,180 @@ +"use client"; +/** + * 노후 하수관로 현황 + */ +import React, { useEffect, useState } from "react"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, + type ChartOptions, +} 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); + +export default function SnOldPpl() { + const [oldPpl2025Data, setOldPpl2025Data] = useState([]); + const [oldPpl2530Data, setOldPpl2530Data] = useState([]); + const [oldPpl30Data, setOldPpl30Data] = useState([]); + const [chartData, setChartData] = useState<{ + labels: string[]; + datasets: ChartDataSet[]; + }>({ labels: [], datasets: [] }); + + 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 = 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"> = { + responsive: true, + maintainAspectRatio: false, + + scales: { + x: { + grid: { + display: true, + }, + }, + y: { + beginAtZero: true, + min: 0, + max: 10000, + ticks: { + stepSize: 2000, + callback: function (value) { + return Number(value).toLocaleString() + 'km'; + } + }, + grid: { + color: 'rgba(200, 200, 200, 0.3)', + } + } + }, + plugins: { + legend: { + position: 'top', + align: 'center', // ⬅ 가운데 정렬 + labels: { + boxWidth: 15, + padding: 20, // ⬅ 범례 항목 간 여백 확대 + usePointStyle: false, + }, + }, + tooltip: { + callbacks: { + label: function (context) { + return `${context.dataset.label}: ${Number(context.parsed.y).toLocaleString()}km`; + } + } + }, + }, + // 총 길이 데이터 표시를 위한 애니메이션 후 함수 설정 + animation: { + onComplete: function (animation) { + const chart = animation.chart; + const ctx = chart.ctx; + + // 각 데이터셋의 마지막 데이터셋의 인덱스 + if (chartData.datasets.length === 0) return; + // const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1); + // 텍스트 스타일 설정 + ctx.font = '11px Arial'; + ctx.fillStyle = '#333'; + ctx.textAlign = 'center'; + + // 각 지역별로 총 길이 표시 + 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); + }); + } + } + }; + + return ( +
+
+

노후 하수관로 현황

+ +
+
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx b/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx new file mode 100644 index 000000000..19ff5fa2c --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx @@ -0,0 +1,202 @@ +"use client"; +/** + * 오수관로 및 우수관로 현황 + */ +import React, { useEffect, useState } from "react"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, + type ChartOptions, +} 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 [sewageData, setSewageData] = useState([]); + const [stormwaterData, setStormwaterData] = useState([]); + const [totalData, setTotalData] = useState([]); + const [chartData, setChartData] = useState<{ + labels: string[]; + datasets: ChartDataSet[]; + }>({ labels: [], datasets: [] }); + + 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: labels.map(zone => dataByZone[zone]?.sewage), + backgroundColor: "rgba(180, 240, 190, 0.95)", + }); + + // 우수관로 데이터셋 + datasets.push({ + label: "우수관로", + 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 = chartData.labels.map((_, i) => + chartData.datasets.reduce((s, ds) => s + (ds.data[i] as number), 0) + ); + + const options: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: "top", + }, + tooltip: { + callbacks: { + label: (ctx) => `${ctx.dataset.label}: ${Number(ctx.parsed.y).toLocaleString()}km`, + }, + }, + }, + scales: { + x: { + stacked: true, + }, + y: { + stacked: true, + beginAtZero: true, + ticks: { + callback: (value) => `${Number(value).toLocaleString()}km`, + }, + }, + }, + // 각 스택 위에 총합 표시 + animation: { + onComplete: function (animation) { + const chart = animation.chart; + const ctx = chart.ctx; + const lastMeta = chart.getDatasetMeta(chartData.datasets.length - 1); + ctx.save(); + ctx.fillStyle = "#111"; + ctx.font = "gray bold 12px Arial"; + ctx.textAlign = "center"; + + totals.forEach((total, i) => { + const bar = lastMeta.data[i]; + if (!bar) return; + const x = bar.x; + // y는 마지막 스택의 y 좌표(스택의 최상단)에서 조금 위로 + const y = bar.y - 8; + ctx.fillText(`총 ${total.toLocaleString()}km`, x, y); + }); + + ctx.restore(); + }, + }, + }; + + //const data = { labels, datasets }; + + return ( +
+
+

오수관로 및 우수관로 현황

+ +
+
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts index 25c8b88c9..3ac14a983 100644 --- a/apps/cm/src/shared/config/api.ts +++ b/apps/cm/src/shared/config/api.ts @@ -70,9 +70,16 @@ 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", }, + 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: { GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics", }, diff --git a/apps/dm/.env.local b/apps/dm/.env.local index 93ea58f01..44e7072c3 100644 --- a/apps/dm/.env.local +++ b/apps/dm/.env.local @@ -9,6 +9,7 @@ NEXT_PUBLIC_PROXY_HOST = http://localhost:3005 NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1 +# NEXT_PUBLIC_FO_SERVER_API_URL = http://192.168.1.222:8883/operation-service/api/v1 NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver diff --git a/apps/dm/app/(base-layout)/pages/wrStatus/page.tsx b/apps/dm/app/(base-layout)/pages/wrStatus/page.tsx index e6b6da4d6..f8d9704d4 100644 --- a/apps/dm/app/(base-layout)/pages/wrStatus/page.tsx +++ b/apps/dm/app/(base-layout)/pages/wrStatus/page.tsx @@ -28,11 +28,12 @@ export default function WrStatusPage() { const { Common, dmStatus, getDmStatusData,initializeCommonData} = useDmStore((state) => state); - const { statusData,facilityTotals ,chartData} = dmStatus; + const { year:defaultYear, statusData,facilityTotals ,chartData} = dmStatus; const [selectedRegion, setSelectedRegion] = useState(null); const [hoveredRegion, setHoveredRegion] = useState(null); const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null); - const [chartOption, setChartOption] = useState({} as WrStatusChartProps) + const [chartOption, setChartOption] = useState({} as WrStatusChartProps); + const svgContainerRef = useRef(null); const colorLegend = [ { label: '하수', color: '#FF6B6B', key: 'sewage' }, @@ -40,7 +41,8 @@ export default function WrStatusPage() { { label: '중수도', color: '#45B7D1', key: 'reclaimed' }, ]; const facilityTotalsColor = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FED16A']; - const defaultYear= '2023' + // const [defaultYear, setDefaultYear] = useState(""); + // const defaultYear= '2023'; const handleMarkerClick = (regionId: string) => { @@ -151,11 +153,10 @@ export default function WrStatusPage() { useEffect(() => { - if(Common.selectSidoData&&Common.selectSidoData.length>1) - getDmStatusData(Common.selectSidoData); - },[Common.selectSidoData]) - - + if(Common.selectSidoData&&Common.selectSidoData.length>1) { + getDmStatusData(Common.selectSidoData); + } + },[Common.selectSidoData, getDmStatusData]) useEffect(() => { const loadSVG = async () => { diff --git a/apps/dm/app/store/wrStatus/index.tsx b/apps/dm/app/store/wrStatus/index.tsx index ec8911f51..bd43db731 100644 --- a/apps/dm/app/store/wrStatus/index.tsx +++ b/apps/dm/app/store/wrStatus/index.tsx @@ -8,11 +8,12 @@ import type { SelectDataType } from "../common"; import { makeRegion } from "@/app/common/comFunction"; export type DmStatusActions = { - getDmStatusData(cityCd: SelectDataType[]): void; + getDmStatusData(cityCd: SelectDataType[]): void; // 데이터의 연도 반환 }; export type DmStatusType = { dmStatus: { + year : string; statusData: DmStatusDataType[]; facilityTotals: string[]; chartData: DmStatusChartType[]; @@ -21,6 +22,7 @@ export type DmStatusType = { export const dmStatusState: DmStatusType = { dmStatus: { + year : '', statusData: [], facilityTotals: [], chartData: [], @@ -32,6 +34,7 @@ export const DmStatusReducer = (set: any): DmStatusActions => { return { getDmStatusData: async (cityCd: SelectDataType[]) => { const data = await getFetch(fetchType.FO, "/dashBoard/daemin"); + const year = data.data[0]?.year; // API 응답에서 year 값을 할당합니다. console.log('cityCd ', cityCd) const dataSum = data.data.reduce( (acc: DmStatusDataType, item: DmStatusDataType) => { @@ -73,7 +76,7 @@ export const DmStatusReducer = (set: any): DmStatusActions => { ); data.data.unshift(dataSum); - console.log("dataSum ", dataSum); + //console.log("dataSum ", dataSum); const facilityTotals = [ "하수재이용시설" + "(총" + dataSum?.sewageCnt + "개소)", "빗물재이용시설" + "(총" + dataSum?.rainwaterCnt + "개소)", @@ -101,8 +104,8 @@ export const DmStatusReducer = (set: any): DmStatusActions => { } ).filter((item): item is DmStatusBoardType => item !== undefined); - console.log("boardData ", boardData); - + //console.log("boardData ", boardData); + const chartData = data.data.map((item) => ({ label: makeRegion( cityCd.find((city) => item.region === city.val)?.text ?? "" @@ -116,11 +119,13 @@ export const DmStatusReducer = (set: any): DmStatusActions => { ...state, dmStatus: { ...state.dmStatus, + year : year, statusData: boardData, chartData: chartData, facilityTotals: facilityTotals, }, })); + }, }; }; diff --git a/apps/dm/package.json b/apps/dm/package.json index 07ddec950..8e0eba942 100644 --- a/apps/dm/package.json +++ b/apps/dm/package.json @@ -18,7 +18,7 @@ "js-cookie": "^3.0.5", "js-sha512": "^0.9.0", "lucide-react": "^0.474.0", - "next": "15.1.7", + "next": "15.3.6", "react-hook-form": "^7.54.2", "react-hot-toast": "^2.5.2", "tailwind-merge": "^3.0.1", diff --git a/apps/fo/.env.local b/apps/fo/.env.local index e9db65062..bf6d56f58 100644 --- a/apps/fo/.env.local +++ b/apps/fo/.env.local @@ -5,7 +5,7 @@ NEXT_PUBLIC_AUTH_USER_ID = token-id NEXT_PUBLIC_ACCESS_TOKEN = access-token NEXT_PUBLIC_REFRESH_TOKEN = refresh-token NEXT_PUBLIC_PROXY_HOST = http://localhost:3000 -NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login +#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login NEXT_PUBLIC_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/ NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000 diff --git a/apps/fo/app/page.tsx b/apps/fo/app/page.tsx index cf94377eb..26f528d0e 100644 --- a/apps/fo/app/page.tsx +++ b/apps/fo/app/page.tsx @@ -11,11 +11,18 @@ export default function Home() { const checkToken = getCookie(AUTH_USER_ID); useEffect(() => { + if (!checkToken) { + // 외부 URL로 이동해야 할 경우 location.replace를 사용합니다. + if (window.location.hostname.includes("hasuon.or.kr")) { + location.replace(process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL||'/pages/dashboard'); + return; // 페이지 이동 후에는 더 이상 로직을 실행하지 않습니다. + } + async function doLogin() { try { await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" }); - location.href = "/pages/dashboard"; + router.push("/pages/dashboard"); // location.href 대신 router.push 사용 } catch (err) { console.error("로그인 실패", err); } diff --git a/apps/fo/app/pages/(with-layout)/dashboard/page.tsx b/apps/fo/app/pages/(with-layout)/dashboard/page.tsx index bf15f2493..a6cf99008 100644 --- a/apps/fo/app/pages/(with-layout)/dashboard/page.tsx +++ b/apps/fo/app/pages/(with-layout)/dashboard/page.tsx @@ -210,7 +210,7 @@ export default function DashBoard() { } ]; // 버퍼에 있는 엑셀데이터 전달 후 json으로 변환 - await filePickedBlobArray(blobRet, '시설정보및운영현황황', sheetParameter); + await filePickedBlobArray(blobRet, '시설정보및운영현황', sheetParameter); }); }; break; diff --git a/apps/fo/app/pages/(with-layout)/facilityPrecipitation/modal/page.tsx b/apps/fo/app/pages/(with-layout)/facilityPrecipitation/modal/page.tsx index e90def9b6..1bd8a6c89 100644 --- a/apps/fo/app/pages/(with-layout)/facilityPrecipitation/modal/page.tsx +++ b/apps/fo/app/pages/(with-layout)/facilityPrecipitation/modal/page.tsx @@ -181,11 +181,11 @@ export default function PrecipitationModal(props: PrecipitationModalProps) {
- + {'시설명'} - {props.fcltyName} + {props.fcltyName} {'년월'} - +
(false); // 표 너비 - const [widthSize, setWidthSize] = useState("w-[120%]"); + // const [widthSize, setWidthSize] = useState("w-[120%]"); // 구분별 그리드 헤더 const [upheadList, setUpheadList] = useState(upHeadList1); // 용량별 @@ -97,7 +97,7 @@ export default function FacilityPrecipitation() { */ // 초기화 const resetVal = () => { - setWidthSize("w-[120%]"); + // setWidthSize("w-[120%]"); setUpheadList(upHeadList1) // 상태값 설정 setFcltCpct(selectUpdownData[0]?.val || "0"); @@ -184,7 +184,7 @@ export default function FacilityPrecipitation() { }, [operationValue, fcltyName, searchFacilityPrecipitationList, searchYear, fcltCpct]); const chkVal = useCallback((returntype:ReturnType) => { - setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") + // setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1); setResetPage(true); }, [upHeadList1, upHeadList2, upHeadList3]); @@ -228,8 +228,8 @@ export default function FacilityPrecipitation() { ref={ref} isAdmin={false}/> -
-
+
+
index !== undefined && cellClick(index)} headName={"시설별 강수현황"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
diff --git a/apps/fo/app/pages/(with-layout)/facilityWaterQlty/page.tsx b/apps/fo/app/pages/(with-layout)/facilityWaterQlty/page.tsx index cb4861971..885ee2728 100644 --- a/apps/fo/app/pages/(with-layout)/facilityWaterQlty/page.tsx +++ b/apps/fo/app/pages/(with-layout)/facilityWaterQlty/page.tsx @@ -53,7 +53,7 @@ export default function ProcessFacilitySearch() { // 그리드 관련 const [resetPage, setResetPage] = useState(false); const searchInputRef = useRef(null); - const [widthSize, setWidthSize] = useState("w-[120%]"); + // const [widthSize, setWidthSize] = useState("w-[120%]"); const [upheadList, setUpheadList] = useState(upHeadList1); const [fcltyName, setFcltyName] = useState(""); const [operationValue, setOperationValue] = useState("0"); @@ -91,7 +91,7 @@ export default function ProcessFacilitySearch() { }, [operationValue, fcltyName, searchFacilityWaterQltyList, searchYearValue, fcltCpct]); const resetVal = () => { - setWidthSize("w-[120%]"); + // setWidthSize("w-[120%]"); setUpheadList(upHeadList1) // 상태값 설정 setFcltCpct(selectUpdownData[0]?.val || "0"); @@ -164,7 +164,7 @@ export default function ProcessFacilitySearch() { }, [excelList, excelTrigger]); const chkVal = useCallback((returntype:ReturnType) => { - setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") + // setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1); setResetPage(true); }, [upHeadList1, upHeadList2, upHeadList3]); @@ -205,8 +205,8 @@ export default function ProcessFacilitySearch() { ref={ref} isAdmin={false} initSidoValue={"11"}/> -
-
+
+
index !== undefined && cellClick(index)} headName={"시설별 수질현황"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
diff --git a/apps/fo/app/pages/(with-layout)/flowRate/page.tsx b/apps/fo/app/pages/(with-layout)/flowRate/page.tsx index a7e0933eb..77d96fff6 100644 --- a/apps/fo/app/pages/(with-layout)/flowRate/page.tsx +++ b/apps/fo/app/pages/(with-layout)/flowRate/page.tsx @@ -58,7 +58,7 @@ export default function ProcessFacilitySearch() { const [fcltyName, setFcltyName] = useState(""); const [operationValue, setOperationValue] = useState("0"); const [searchYearValue, setSearchYearValue] = useState(selectSearchYear[0]?.val || "0"); - const [widthSize, setWidthSize] = useState("w-[120%]"); + // const [widthSize, setWidthSize] = useState("w-[120%]"); const [upheadList, setUpheadList] = useState(upHeadList1); const [resetPage, setResetPage] = useState(false); const prevExcelListRef = useRef(null); @@ -96,7 +96,7 @@ export default function ProcessFacilitySearch() { }, [searchFlowRateList, searchYearValue, fcltCpct, operationValue, fcltyName]); const resetVal = () => { - setWidthSize("w-[120%]"); + // setWidthSize("w-[120%]"); setUpheadList(upHeadList1) // 상태값 설정 setSearchYearValue(selectSearchYear[0]?.val || "0"); @@ -170,7 +170,7 @@ export default function ProcessFacilitySearch() { }); const chkVal = useCallback((returntype:ReturnType) => { - setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") + // setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1); setResetPage(true); }, [upHeadList1, upHeadList2, upHeadList3]); @@ -206,8 +206,8 @@ export default function ProcessFacilitySearch() { ref={ref} isAdmin={false} initSidoValue={'11'}/> -
-
+
+
index !== undefined && cellClick(index)} headName={"시설별 유량현황"} tableData={flowRateList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
diff --git a/apps/fo/app/pages/(with-layout)/planSewage/page.tsx b/apps/fo/app/pages/(with-layout)/planSewage/page.tsx index fa993715e..1f8bd1e94 100644 --- a/apps/fo/app/pages/(with-layout)/planSewage/page.tsx +++ b/apps/fo/app/pages/(with-layout)/planSewage/page.tsx @@ -75,7 +75,7 @@ export default function PlanSewage() { // 초기화 변수 const [resetPage, setResetPage] = useState(false); // 표 너비 - const [widthSize, setWidthSize] = useState("w-[120%]"); + // const [widthSize, setWidthSize] = useState("w-[120%]"); // 구분별 그리드 헤더 const [upheadList, setUpheadList] = useState(upHeadList1); // 용량별 @@ -95,7 +95,7 @@ export default function PlanSewage() { */ // 초기화 const resetVal = () => { - setWidthSize("w-[120%]"); + // setWidthSize("w-[120%]"); setUpheadList(upHeadList1); // 상태값 설정 setFcltCpct(selectUpdownData[0]?.val || "0"); @@ -171,7 +171,7 @@ export default function PlanSewage() { }, [isInit, initializeCommonData, searchPlanSewageList, selectSearchYear, initTrigger, searchPlanSewage]); const chkVal = useCallback((returntype:ReturnType) => { - setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") + // setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1); setResetPage(true); }, [upHeadList1, upHeadList2, upHeadList3]); @@ -215,8 +215,8 @@ export default function PlanSewage() { ref={ref} isAdmin={false} initSidoValue={"11"}/> -
-
+
+
diff --git a/apps/fo/app/pages/(with-layout)/processArea/page.tsx b/apps/fo/app/pages/(with-layout)/processArea/page.tsx index 94be9bd0b..91bdb6ab0 100644 --- a/apps/fo/app/pages/(with-layout)/processArea/page.tsx +++ b/apps/fo/app/pages/(with-layout)/processArea/page.tsx @@ -161,8 +161,8 @@ export default function ProcessArea() { /> -
-
+
+
index !== undefined && cellClick(index)} headName={"처리구역정보"} tableData={mainList} headlist={upHeadList1} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
diff --git a/apps/fo/app/pages/(with-layout)/processFacility/page.tsx b/apps/fo/app/pages/(with-layout)/processFacility/page.tsx index 775dd1a45..856ab4631 100644 --- a/apps/fo/app/pages/(with-layout)/processFacility/page.tsx +++ b/apps/fo/app/pages/(with-layout)/processFacility/page.tsx @@ -87,7 +87,7 @@ export default function ProcessFacilitySearch() { const [resetPage, setResetPage] = useState(false); const [partValue, setPartValue] = useState("A"); - const [widthSize, setWidthSize] = useState("w-[120%]"); + // const [widthSize, setWidthSize] = useState("w-[120%]"); const [upHeadList, setUpheadList] = useState(upHeadList1); /** @@ -135,15 +135,15 @@ export default function ProcessFacilitySearch() { useEffect(() => { switch (partValue) { case 'A': - setWidthSize("w-[130%]"); + // setWidthSize("w-[130%]"); setUpheadList(upHeadList1); break; case 'R': - setWidthSize("w-[140%]"); + // setWidthSize("w-[140%]"); setUpheadList(upHeadList2); break; case 'G': - setWidthSize("w-[150%]"); + // setWidthSize("w-[150%]"); setUpheadList(upHeadList3); break; } @@ -270,8 +270,8 @@ export default function ProcessFacilitySearch() { ref={ref} isAdmin={false} /> -
-
+
+
index !== undefined && cellClick(index)} headName={"처리시설 시설정보"} diff --git a/apps/fo/app/pages/(with-layout)/rdi/page.tsx b/apps/fo/app/pages/(with-layout)/rdi/page.tsx index f581c0def..47142e0a1 100644 --- a/apps/fo/app/pages/(with-layout)/rdi/page.tsx +++ b/apps/fo/app/pages/(with-layout)/rdi/page.tsx @@ -73,7 +73,7 @@ export default function Rdi() { // 초기화 변수 const [resetPage, setResetPage] = useState(false); // 표 너비 - const [widthSize, setWidthSize] = useState("w-[130%]"); + // const [widthSize, setWidthSize] = useState("w-[130%]"); // 구분별 그리드 헤더 const [upheadList, setUpheadList] = useState(upHeadList1); // 용량별 @@ -93,7 +93,7 @@ export default function Rdi() { */ // 초기화 const resetVal = () => { - setWidthSize("w-[120%]"); + // setWidthSize("w-[120%]"); setUpheadList(upHeadList1) // 상태값 설정 @@ -168,7 +168,7 @@ export default function Rdi() { }, [isInit, initializeCommonData, searchRdiList, selectSearchYear, searchRdi, initTrigger]); const chkVal = useCallback((returntype:ReturnType) => { - setWidthSize(returntype.partValue=='A' ?"w-[130%]":"w-[150%]") + // setWidthSize(returntype.partValue=='A' ?"w-[130%]":"w-[150%]") setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1); setResetPage(true); }, [upHeadList1, upHeadList2, upHeadList3]); @@ -213,8 +213,8 @@ export default function Rdi() { ref={ref} isAdmin={false} initSidoValue={"11"}/> -
-
+
+
diff --git a/apps/fo/app/pages/(with-layout)/sewageOccur/modal/page.tsx b/apps/fo/app/pages/(with-layout)/sewageOccur/modal/page.tsx index a2ffb386a..bc7fec4e8 100644 --- a/apps/fo/app/pages/(with-layout)/sewageOccur/modal/page.tsx +++ b/apps/fo/app/pages/(with-layout)/sewageOccur/modal/page.tsx @@ -90,15 +90,15 @@ export default function SewageOccurModal(props: { fcltyName: string; facilityCd return ( <> - -
+ +
{'시설명'} - {props.fcltyName} + {props.fcltyName} {'년월'} - + { diff --git a/apps/fo/app/pages/(with-layout)/sewageOccur/page.tsx b/apps/fo/app/pages/(with-layout)/sewageOccur/page.tsx index e7bae003f..001e816ae 100644 --- a/apps/fo/app/pages/(with-layout)/sewageOccur/page.tsx +++ b/apps/fo/app/pages/(with-layout)/sewageOccur/page.tsx @@ -83,7 +83,7 @@ export default function PlanSewage() { // 초기화 변수 const [resetPage, setResetPage] = useState(false); // 표 너비 - const [widthSize, setWidthSize] = useState("w-[120%]"); + //const [widthSize, setWidthSize] = useState("w-[120%]"); // 구분별 그리드 헤더 const [upheadList, setUpheadList] = useState(upHeadList1); // 용량별 @@ -102,7 +102,7 @@ export default function PlanSewage() { */ // 초기화 const resetVal = () => { - setWidthSize("w-[120%]"); + // setWidthSize("w-[120%]"); setUpheadList(upHeadList1) // 상태값 설정 setFcltCpct(selectUpdownData[0]?.val || "0"); @@ -189,7 +189,7 @@ export default function PlanSewage() { }, [fcltCpct, operationValue, fcltyName, searchSewageOccurList, searchYear]); const chkVal = useCallback((returntype:ReturnType) => { - setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") + // setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1); setResetPage(true); }, [upHeadList1, upHeadList2, upHeadList3]); @@ -236,8 +236,8 @@ export default function PlanSewage() { /> -
-
+
+
index !== undefined && cellClick(index)} headName={"오수발생량 추정"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true} />
diff --git a/apps/fo/app/pages/(with-layout)/underWaterInput/page.tsx b/apps/fo/app/pages/(with-layout)/underWaterInput/page.tsx index 8ac105698..47e9ec912 100644 --- a/apps/fo/app/pages/(with-layout)/underWaterInput/page.tsx +++ b/apps/fo/app/pages/(with-layout)/underWaterInput/page.tsx @@ -88,7 +88,7 @@ export default function UnderWaterInput() { // 초기화 변수 const [resetPage, setResetPage] = useState(false); // 표 너비 - const [widthSize, setWidthSize] = useState("w-[120%]"); + // const [widthSize, setWidthSize] = useState("w-[120%]"); // 구분별 그리드 헤더 const [upheadList, setUpheadList] = useState(upHeadList1); // 용량별 @@ -107,7 +107,7 @@ export default function UnderWaterInput() { */ // 초기화 const resetVal = () => { - setWidthSize("w-[120%]"); + // setWidthSize("w-[120%]"); setUpheadList(upHeadList1) // 상태값 설정 setFcltCpct(selectUpdownData[0]?.val || "0"); @@ -193,7 +193,7 @@ export default function UnderWaterInput() { }, [fcltCpct, operationValue, fcltyName, searchUnderWaterList, searchYear]); const chkVal = useCallback((returntype:ReturnType) => { - setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") + // setWidthSize(returntype.partValue=='A' ?"w-[120%]":"w-[130%]") setUpheadList(returntype.partValue == 'R' ? upHeadList2 : returntype.partValue == 'G'?upHeadList3:upHeadList1); setResetPage(true); }, [upHeadList1, upHeadList2, upHeadList3]); @@ -240,8 +240,8 @@ export default function UnderWaterInput() { /> -
-
+
+
index !== undefined && cellClick(index)} headName={"지하수유입량 추정"} tableData={mainList} headlist={upheadList} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange} widthFixed={true}/>
diff --git a/apps/fo/app/store/dashboard/index.tsx b/apps/fo/app/store/dashboard/index.tsx index 906ce8e3b..b5f678a22 100644 --- a/apps/fo/app/store/dashboard/index.tsx +++ b/apps/fo/app/store/dashboard/index.tsx @@ -234,14 +234,21 @@ const upHeadListFclty:TableUpperProps[] = [ const fcltyList : mainFcltyProps[] = [ ] -let planYearList : string[] = [ - "2021", - "2022", - "2023", - "2024", - "2025", - "2026" -] +function getPlanYearList(): string[] { + const currentYear = new Date().getFullYear(); // 올해 + const startYear = currentYear - 4; // 5년 전 + const endYear = currentYear + 1; // 내년 + + const years: string[] = []; + + for (let y = startYear; y <= endYear; y++) { + years.push(String(y)); // 혹은 y.toString() + } + + return years; +} + +let planYearList : string[] = getPlanYearList(); const upHeadListPlanFclty : TableUpperProps[] = [ { id: "item", title: "", upName:"upChangeRe", upSequnce:1 }, @@ -253,6 +260,7 @@ const upHeadListPlanFclty : TableUpperProps[] = [ { id: "stepFour", title: "4단계", upName:"upChangeRe", upSequnce:7 }, ] +// 시설계획 좌측 상단 const planFcltyList:planFcltyListProps[] = [ { item : "1.증설계획\n(㎥/일)", @@ -781,61 +789,45 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl if(item.tpInflWqltTp != null && item.tpInflWqltTp != 0){ noNullWinterDay+=1; } - const inflbod = nullToZero(item.inflAssctBfrWqltBod); - const infltn = nullToZero(item.inflAssctBfrWqltTn); - const infltp = nullToZero(item.inflAssctBfrWqltTp); - const effbod = nullToZero(item.efflWqltBod); - const efftn = nullToZero(item.efflWqltTn); - const efftp = nullToZero(item.efflWqltTp); - const tpInfl = nullToZero(item.tpInflWqltTp); - - totalInflBodAvgWinter += inflbod; - totalInflTnAvgWinter += infltn; - totalInflTpAvgWinter += infltp; - totalEfflBodAvgWinter += effbod; - totalEfflTnAvgWinter += efftn; - totalEfflTpAvgWinter += efftp; - totalTpInflAvgWinter += tpInfl; + totalInflBodAvgWinter += nullToZero(item.inflAssctBfrWqltBod); + totalInflTnAvgWinter += nullToZero(item.inflAssctBfrWqltTn); + totalInflTpAvgWinter += nullToZero(item.inflAssctBfrWqltTp); + totalEfflBodAvgWinter += nullToZero(item.efflWqltBod); + totalEfflTnAvgWinter += nullToZero(item.efflWqltTn); + totalEfflTpAvgWinter += nullToZero(item.efflWqltTp); + totalTpInflAvgWinter += nullToZero(item.tpInflWqltTp); }else if(month === '06' || month === '07' || month === '08'){ summerDay += 1; if(item.tpInflWqltTp != null && item.tpInflWqltTp != 0){ noNullSummerDay+=1; } - const inflbod = nullToZero(item.inflAssctBfrWqltBod); - const infltn = nullToZero(item.inflAssctBfrWqltTn); - const infltp = nullToZero(item.inflAssctBfrWqltTp); - const effbod = nullToZero(item.efflWqltBod); - const efftn = nullToZero(item.efflWqltTn); - const efftp = nullToZero(item.efflWqltTp); - const tpInfl = nullToZero(item.tpInflWqltTp); - - totalInflBodAvgSummer += inflbod; - totalInflTnAvgSummer += infltn; - totalInflTpAvgSummer += infltp; - totalEfflBodAvgSummer += effbod; - totalEfflTnAvgSummer += efftn; - totalEfflTpAvgSummer += efftp; - totalTpInflAvgSummer += tpInfl; + totalInflBodAvgSummer += nullToZero(item.inflAssctBfrWqltBod); + totalInflTnAvgSummer += nullToZero(item.inflAssctBfrWqltTn); + totalInflTpAvgSummer += nullToZero(item.inflAssctBfrWqltTp); + totalEfflBodAvgSummer += nullToZero(item.efflWqltBod); + totalEfflTnAvgSummer += nullToZero(item.efflWqltTn); + totalEfflTpAvgSummer += nullToZero(item.efflWqltTp); + totalTpInflAvgSummer += nullToZero(item.tpInflWqltTp); } }); - orgData[5].winterAll = qltyDataSetting(totalInflBodAvgWinter, winterDay, 1); - orgData[6].winterAll = qltyDataSetting(totalEfflBodAvgWinter, winterDay, 1); - orgData[7].winterAll = qltyDataSetting(totalInflTnAvgWinter, winterDay, 3); - orgData[8].winterAll = qltyDataSetting(totalEfflTnAvgWinter, winterDay, 3); - orgData[9].winterAll = qltyDataSetting(totalInflTpAvgWinter, winterDay, 3); - orgData[10].winterAll = qltyDataSetting(totalEfflTpAvgWinter, winterDay, 3); + orgData[5].winterAll = qltyDataSetting(totalInflBodAvgWinter, winterDay, 1); // 동절기 유입BOD + orgData[6].winterAll = qltyDataSetting(totalEfflBodAvgWinter, winterDay, 1); // 동절기 방류BOD + orgData[7].winterAll = qltyDataSetting(totalInflTnAvgWinter, winterDay, 3); // 동절기 유입TN + orgData[8].winterAll = qltyDataSetting(totalEfflTnAvgWinter, winterDay, 3); // 동절기 방류TN + orgData[9].winterAll = qltyDataSetting(totalInflTpAvgWinter, winterDay, 3); // 동절기 유입TP + orgData[10].winterAll = qltyDataSetting(totalEfflTpAvgWinter, winterDay, 3); // 동절기 방류TP //총인설비 orgData[11].winterAll = qltyDataSetting(totalTpInflAvgWinter, noNullWinterDay, 3); orgData[12].winterAll = qltyDataSetting(totalEfflTpAvgWinter, winterDay, 3); - orgData[5].summerAll = qltyDataSetting(totalInflBodAvgSummer, summerDay, 1); - orgData[6].summerAll = qltyDataSetting(totalEfflBodAvgSummer, summerDay, 1); - orgData[7].summerAll = qltyDataSetting(totalInflTnAvgSummer, summerDay, 3); - orgData[8].summerAll = qltyDataSetting(totalEfflTnAvgSummer, summerDay, 3); - orgData[9].summerAll = qltyDataSetting(totalInflTpAvgSummer, summerDay, 3); - orgData[10].summerAll = qltyDataSetting(totalEfflTpAvgSummer, summerDay, 3); + orgData[5].summerAll = qltyDataSetting(totalInflBodAvgSummer, summerDay, 1); // 하절기 유입BOD + orgData[6].summerAll = qltyDataSetting(totalEfflBodAvgSummer, summerDay, 1); // 하절기 방류BOD + orgData[7].summerAll = qltyDataSetting(totalInflTnAvgSummer, summerDay, 3); // 하절기 유입TN + orgData[8].summerAll = qltyDataSetting(totalEfflTnAvgSummer, summerDay, 3); // 하절기 방류TN + orgData[9].summerAll = qltyDataSetting(totalInflTpAvgSummer, summerDay, 3); // 하절기 유입TP + orgData[10].summerAll = qltyDataSetting(totalEfflTpAvgSummer, summerDay, 3); // 하절기 방류TP //총인설비 orgData[11].summerAll = qltyDataSetting(totalTpInflAvgSummer, noNullSummerDay, 3); @@ -849,37 +841,37 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl let noNullChungchunWinterDay = 0; // (1-2) 하절기 - let chungchunInflBodAvgSummer = 0; - let chungchunInflTnAvgSummer = 0; - let chungchunInflTpAvgSummer = 0; - let chungchunEfflBodAvgSummer = 0; - let chungchunEfflTnAvgSummer = 0; - let chungchunEfflTpAvgSummer = 0; - let chungchunTpInflAvgSummer = 0; + let chungchunInflBodAvgSummer = 0; // 하절기 청천시 유입BOD + let chungchunInflTnAvgSummer = 0; // 하절기 청천시 유입TN + let chungchunInflTpAvgSummer = 0; // 하절기 청천시 유입TP + let chungchunEfflBodAvgSummer = 0; // 하절기 청천시 방류BOD + let chungchunEfflTnAvgSummer = 0; // 하절기 청천시 방류TN + let chungchunEfflTpAvgSummer = 0; // 하절기 청천시 방류TP + let chungchunTpInflAvgSummer = 0; // 하절기 청천시 총인설비 유입TP // (1-3) 동절기 (최대, 최소, 평균 다 필요) let chungchunInflBodAvg = 0; - let chungchunInflTnAvg = 0; - let chungchunInflTpAvg = 0; - let chungchunTpInflAvg = 0; + let chungchunInflTnAvg = 0; + let chungchunInflTpAvg = 0; + let chungchunTpInflAvg = 0; let chungchunInflBodMax = 0; - let chungchunInflTnMax= 0; - let chungchunInflTpMax = 0; - let chungchunTpInflMax = 0; + let chungchunInflTnMax = 0; + let chungchunInflTpMax = 0; + let chungchunTpInflMax = 0; let chungchunInflBodMin = 99999999; - let chungchunInflTnMin = 99999999; - let chungchunInflTpMin = 99999999; - let chungchunTpInflMin = 99999999; + let chungchunInflTnMin = 99999999; + let chungchunInflTpMin = 99999999; + let chungchunTpInflMin = 99999999; let chungchunEfflBodAvg = 0; - let chungchunEfflTnAvg = 0; - let chungchunEfflTpAvg = 0; + let chungchunEfflTnAvg = 0; + let chungchunEfflTpAvg = 0; let chungchunEfflBodMax = 0; - let chungchunEfflTnMax= 0; - let chungchunEfflTpMax = 0; + let chungchunEfflTnMax = 0; + let chungchunEfflTpMax = 0; let chungchunEfflBodMin = 99999999; - let chungchunEfflTnMin = 99999999; - let chungchunEfflTpMin = 99999999; + let chungchunEfflTnMin = 99999999; + let chungchunEfflTpMin = 99999999; // 청천시 qltyChungchunData.forEach((item: any) => { @@ -891,57 +883,49 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl } const inflbod = nullToZero(item.inflAssctBfrWqltBod); - const infltn = nullToZero(item.inflAssctBfrWqltTn); - const infltp = nullToZero(item.inflAssctBfrWqltTp); - const effbod = nullToZero(item.efflWqltBod); - const efftn = nullToZero(item.efflWqltTn); - const efftp = nullToZero(item.efflWqltTp); - const tpInfl = nullToZero(item.tpInflWqltTp); + const infltn = nullToZero(item.inflAssctBfrWqltTn); + const infltp = nullToZero(item.inflAssctBfrWqltTp); + const effbod = nullToZero(item.efflWqltBod); + const efftn = nullToZero(item.efflWqltTn); + const efftp = nullToZero(item.efflWqltTp); + const tpInfl = nullToZero(item.tpInflWqltTp); chungchunInflBodAvg += inflbod; - chungchunInflTnAvg += infltn; - chungchunInflTpAvg += infltp; - chungchunTpInflAvg += tpInfl; + chungchunInflTnAvg += infltn; + chungchunInflTpAvg += infltp; chungchunEfflBodAvg += effbod; - chungchunEfflTnAvg += efftn; - chungchunEfflTpAvg += efftp; + chungchunEfflTnAvg += efftn; + chungchunEfflTpAvg += efftp; + chungchunTpInflAvg += tpInfl; if(chungchunInflBodMax < inflbod) chungchunInflBodMax = inflbod; - if(chungchunInflTnMax < infltn) chungchunInflTnMax = infltn; - if(chungchunInflTpMax < infltp) chungchunInflTpMax = infltp; - if(chungchunTpInflMax < tpInfl) chungchunTpInflMax = tpInfl; - if(chungchunEfflBodMax < effbod) chungchunEfflBodMax = effbod; - if(chungchunEfflTnMax < efftn) chungchunEfflTnMax = efftn; - if(chungchunEfflTpMax < efftp) chungchunEfflTpMax = efftp; + if(chungchunInflTnMax < infltn) chungchunInflTnMax = infltn; + if(chungchunInflTpMax < infltp) chungchunInflTpMax = infltp; + if(chungchunEfflBodMax < effbod) chungchunEfflBodMax = effbod; + if(chungchunEfflTnMax < efftn) chungchunEfflTnMax = efftn; + if(chungchunEfflTpMax < efftp) chungchunEfflTpMax = efftp; + if(chungchunTpInflMax < tpInfl) chungchunTpInflMax = tpInfl; if(chungchunInflBodMin > inflbod) chungchunInflBodMin = inflbod; - if(chungchunInflTnMin > infltn) chungchunInflTnMin = infltn; - if(chungchunInflTpMin > infltp) chungchunInflTpMin = infltp; - if(chungchunTpInflMin > tpInfl) chungchunTpInflMin = tpInfl; - if(chungchunEfflBodMin > effbod) chungchunEfflBodMin = effbod; - if(chungchunEfflTnMin > efftn) chungchunEfflTnMin = efftn; - if(chungchunEfflTpMin > efftp) chungchunEfflTpMin = efftp; + if(chungchunInflTnMin > infltn) chungchunInflTnMin = infltn; + if(chungchunInflTpMin > infltp) chungchunInflTpMin = infltp; + if(chungchunEfflBodMin > effbod) chungchunEfflBodMin = effbod; + if(chungchunEfflTnMin > efftn) chungchunEfflTnMin = efftn; + if(chungchunEfflTpMin > efftp) chungchunEfflTpMin = efftp; + if(chungchunTpInflMin > tpInfl) chungchunTpInflMin = tpInfl; }else if(month === '06' || month === '07' || month === '08'){ chungchunSummerDay += 1; if(item.tpInflWqltTp != null && item.tpInflWqltTp != 0){ noNullChungchunSummerDay+=1; } - const inflbod = nullToZero(item.inflAssctBfrWqltBod); - const infltn = nullToZero(item.inflAssctBfrWqltTn); - const infltp = nullToZero(item.inflAssctBfrWqltTp); - const effbod = nullToZero(item.efflWqltBod); - const efftn = nullToZero(item.efflWqltTn); - const efftp = nullToZero(item.efflWqltTp); - const tpInfl = nullToZero(item.tpInflWqltTp); - - chungchunInflBodAvgSummer += inflbod; - chungchunInflTnAvgSummer += infltn; - chungchunInflTpAvgSummer += infltp; - chungchunEfflBodAvgSummer += effbod; - chungchunEfflTnAvgSummer += efftn; - chungchunEfflTpAvgSummer += efftp; - chungchunTpInflAvgSummer += tpInfl; + chungchunInflBodAvgSummer += nullToZero(item.inflAssctBfrWqltBod); + chungchunInflTnAvgSummer += nullToZero(item.inflAssctBfrWqltTn); + chungchunInflTpAvgSummer += nullToZero(item.inflAssctBfrWqltTp); + chungchunEfflBodAvgSummer += nullToZero(item.efflWqltBod); + chungchunEfflTnAvgSummer += nullToZero(item.efflWqltTn); + chungchunEfflTpAvgSummer += nullToZero(item.efflWqltTp); + chungchunTpInflAvgSummer += nullToZero(item.tpInflWqltTp); } }); @@ -1009,21 +993,13 @@ const calcWaterQltyData = async (data: any[], searchYear : string|undefined, fcl noNullRainSummerDay+=1; } - const inflbod = nullToZero(item.inflAssctBfrWqltBod); - const infltn = nullToZero(item.inflAssctBfrWqltTn); - const infltp = nullToZero(item.inflAssctBfrWqltTp); - const effbod = nullToZero(item.efflWqltBod); - const efftn = nullToZero(item.efflWqltTn); - const efftp = nullToZero(item.efflWqltTp); - const tpInfl = nullToZero(item.tpInflWqltTp); - - rainInflBodAvg += inflbod; - rainInflTnAvg += infltn; - rainInflTpAvg += infltp; - rainEfflBodAvg += effbod; - rainEfflTnAvg += efftn; - rainEfflTpAvg += efftp; - rainTpInflAvg += tpInfl; + rainInflBodAvg += nullToZero(item.inflAssctBfrWqltBod); + rainInflTnAvg += nullToZero(item.inflAssctBfrWqltTn); + rainInflTpAvg += nullToZero(item.inflAssctBfrWqltTp); + rainEfflBodAvg += nullToZero(item.efflWqltBod); + rainEfflTnAvg += nullToZero(item.efflWqltTn); + rainEfflTpAvg += nullToZero(item.efflWqltTp); + rainTpInflAvg += nullToZero(item.tpInflWqltTp); } }); @@ -1367,15 +1343,15 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) planYearList.push(rtnPlanData?rtnPlanData.planYrDc3.toString():""); planYearList.push(rtnPlanData?rtnPlanData.planYrDc4.toString():""); - if(index === 0){ - el.provisionalApproval = rtnPlanData?rtnPlanData.planSwageQntSumDt4:''; - el.now = rtnPlanData?rtnPlanData.planSwageQntSum:''; - el.stepOne = rtnPlanData?rtnPlanData.planSwageQntSumDc1:''; - el.stepTwo = rtnPlanData?rtnPlanData.planSwageQntSumDc2:''; - el.stepThree = rtnPlanData?rtnPlanData.planSwageQntSumDc3:''; - el.stepFour = rtnPlanData?rtnPlanData.planSwageQntSumDc4:''; + if(index === 0){ // 증설계획(㎥/일) + el.provisionalApproval = rtnPlanData?rtnPlanData.planSwageQntSumDt4:''; // 기승인4단계 + el.now = rtnPlanData?rtnPlanData.planSwageQntSum:''; // 현재 + el.stepOne = rtnPlanData?rtnPlanData.planSwageQntSumDc1:''; // 1단계 + el.stepTwo = rtnPlanData?rtnPlanData.planSwageQntSumDc2:''; // 2단계 + el.stepThree = rtnPlanData?rtnPlanData.planSwageQntSumDc3:''; // 3단계 + el.stepFour = rtnPlanData?rtnPlanData.planSwageQntSumDc4:''; // 4단계 } - if(index === 1){ + if(index === 1){ // 처리인구(인) el.provisionalApproval = rtnPlanData?rtnPlanData.swtrmtPpltnCntDt4:''; el.now = rtnPlanData?rtnPlanData.swtrmtPpltnCnt:''; el.stepOne = rtnPlanData?rtnPlanData.swtrmtPpltnCntDc1:''; @@ -1383,7 +1359,7 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) el.stepThree = rtnPlanData?rtnPlanData.swtrmtPpltnCntDc3:''; el.stepFour = rtnPlanData?rtnPlanData.swtrmtPpltnCntDc4:''; } - if(index === 2){ + if(index === 2){ // 처리구역(km²) el.provisionalApproval = rtnPlanData?rtnPlanData.swtrmtZoneAreaDt4:''; el.now = rtnPlanData?rtnPlanData.swtrmtZoneArea:''; el.stepOne = rtnPlanData?rtnPlanData.swtrmtZoneAreaDc1:''; @@ -1391,13 +1367,13 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) el.stepThree = rtnPlanData?rtnPlanData.swtrmtZoneAreaDc3:''; el.stepFour = rtnPlanData?rtnPlanData.swtrmtZoneAreaDc4:''; } - if(index === 4){ + if(index === 4){ // 물사용량 el.provisionalApproval = rtnPlanData?rtnPlanData.ugwtUseSum:''; } - if(index === 2){ + if(index === 2){ // 처리구역(km²) dataFormSettingPlan(el, 1); - }else if(index == 4){ + }else if(index == 4){ // 물사용량 }else{ dataFormSettingPlan(el, 0); } @@ -1423,40 +1399,40 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) isLoadingOperationStatus: true, }, })); - const res = await fetchOperationStatus(searchYear, fcltyCd); + const res = await fetchOperationStatus(searchYear, fcltyCd); // /dashBoard/mngInfo/ const data = res.data; - let yearMaxSumFlow = 1; - let yearMinSumFlow = 1; - let yearAvgSumFlow = 1; - let summerAll = 1; - let summerThree = 1; - let summerRain = 1; - let winterAll = 1; - let winterSunnyMin = 1; - let winterSunnyAvg = 1; - let winterSunnyMax = 1; + let yearMaxSumFlow = 1; // 연간운영실태 최대 + let yearMinSumFlow = 1; // 연간운영실태 최소 + let yearAvgSumFlow = 1; // 연간운영실태 평균 + let summerAll = 1; // 하절기 전기간 + let summerThree = 1; // 하절기 + let summerRain = 1; // 하절기 + let winterAll = 1; // 동절기 전기간 + let winterSunnyMin = 1; // 동절기 청천 최소 + let winterSunnyAvg = 1; // 동절기 청천 평균 + let winterSunnyMax = 1; // 동절기 청천 최대 // 최대, 최소 총 유입량 (1년 하루 합계의 최대, 최소) // 하절기 청천시, 강우시 재계산 리턴값 - let summerChungchunFlow = 1 - let summerChungchunReturnAmnt = 1; - let summerChungchunEfflAmnt = 1; - let summerRainInflAmnt = 1; - let summerRainReturnAmnt = 1; - let summerRainEfflAmnt = 1; + let summerChungchunFlow = 1; // 하절기 청천 + let summerChungchunReturnAmnt = 1; // 하절기 청천 + let summerChungchunEfflAmnt = 1; // 하절기 청천 + let summerRainInflAmnt = 1; // 하절기 강우시 + let summerRainReturnAmnt = 1; // 하절기 강우시 + let summerRainEfflAmnt = 1; // 하절기 강우시 // 동절기 청천시 최대, 최소, 평균 리턴값 - let winterChungchunFlowAvg = 1 ; - let winterChungchunFlowMax = 1 ; - let winterChungchunFlowMin = 1 ; - let winterChungchunReturnAvg = 1 ; - let winterChungchunReturnMax = 1 ; - let winterChungchunReturnMin = 1 ; - let winterChungchunEfflAvg = 1 ; - let winterChungchunEfflMax = 1 ; - let winterChungchunEfflMin = 1 ; + let winterChungchunFlowAvg = 1; // 동절기 청천 하수유입량 평균 + let winterChungchunFlowMax = 1; // 동절기 청천 하수유입량 최대 + let winterChungchunFlowMin = 1; // 동절기 청천 하수유입량 최소 + let winterChungchunReturnAvg = 1; // 동절기 청천 총인반류수 평균 + let winterChungchunReturnMax = 1; // 동절기 청천 총인반류수 최대 + let winterChungchunReturnMin = 1; // 동절기 청천 총인반류수 최소 + let winterChungchunEfflAvg = 1; // 동절기 청천 방류량 평균 + let winterChungchunEfflMax = 1; // 동절기 청천 방류량 최대 + let winterChungchunEfflMin = 1; // 동절기 청천 방류량 최소 const clone = (arr: any[]) => arr?.map(v => ({ ...v })) ?? []; @@ -1467,19 +1443,19 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) if (operationalStatusList?.[0]) { // 연간 const yearAvg = data.yearData===null ? '' : ( - (data.yearData.AVG_INFL_AMNT?data.yearData.AVG_INFL_AMNT:0) + - (data.yearData.AVG_SUM_FLOW?data.yearData.AVG_SUM_FLOW:0) + - (data.yearData.AVG_RETURN_AMNT?data.yearData.AVG_RETURN_AMNT:0) + (data.yearData.AVG_INFL_AMNT?data.yearData.AVG_INFL_AMNT:0) + // 실제유입량(반류수제거)(m³/일) 평균 + (data.yearData.AVG_SUM_FLOW?data.yearData.AVG_SUM_FLOW:0) + // api 주석처리되어 있음. + (data.yearData.AVG_RETURN_AMNT?data.yearData.AVG_RETURN_AMNT:0) // 반류수유량(m³/일) 평균 ); const yearMax = data.yearData===null ? '' : ( - (data.yearData.MAX_INFL_AMNT?data.yearData.MAX_INFL_AMNT:0) + - (data.yearData.MAX_SUM_FLOW?data.yearData.MAX_SUM_FLOW:0) + - (data.yearData.MAX_RETURN_AMNT?data.yearData.MAX_RETURN_AMNT:0) + (data.yearData.MAX_INFL_AMNT?data.yearData.MAX_INFL_AMNT:0) + // 실제유입량(반류수제거)(m³/일) 최대 + (data.yearData.MAX_SUM_FLOW?data.yearData.MAX_SUM_FLOW:0) + // api 주석처리되어 있음. + (data.yearData.MAX_RETURN_AMNT?data.yearData.MAX_RETURN_AMNT:0) // 반류수유량(m³/일) 최대 ); const yearMin = data.yearData===null ? '' : ( - (data.yearData.MIN_INFL_AMNT?data.yearData.MIN_INFL_AMNT:0) + - (data.yearData.MIN_SUM_FLOW?data.yearData.MIN_SUM_FLOW:0) + - (data.yearData.MIN_RETURN_AMNT?data.yearData.MIN_RETURN_AMNT:0) + (data.yearData.MIN_INFL_AMNT?data.yearData.MIN_INFL_AMNT:0) + // 실제유입량(반류수제거)(m³/일) 최소 + (data.yearData.MIN_SUM_FLOW?data.yearData.MIN_SUM_FLOW:0) + // api 주석처리되어 있음. + (data.yearData.MIN_RETURN_AMNT?data.yearData.MIN_RETURN_AMNT:0) // 반류수유량(m³/일) 최소 ); operationalStatusList[0].yearAvg = yearAvg; @@ -1490,31 +1466,31 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) (data.summer.AVG_INFL_AMNT ? data.summer.AVG_INFL_AMNT : 0) + (data.summer.AVG_SUM_FLOW ? data.summer.AVG_SUM_FLOW : 0) ); - operationalStatusList[0].summerThree = data.summerRainy === null ? '' : ( + operationalStatusList[0].summerThree = data.summerRainy === null ? '' : ( // 하절기 강우시 (data.summerRainy.AVG_INFL_AMNT ? data.summerRainy.AVG_INFL_AMNT : 0) + (data.summerRainy.AVG_SUM_FLOW ? data.summerRainy.AVG_SUM_FLOW : 0) ); - operationalStatusList[0].summerRain = data.summerSunny === null ? '' : ( + operationalStatusList[0].summerRain = data.summerSunny === null ? '' : ( // 하절기 청천시 (data.summerSunny.AVG_INFL_AMNT ? data.summerSunny.AVG_INFL_AMNT : 0) + (data.summerSunny.AVG_SUM_FLOW ? data.summerSunny.AVG_SUM_FLOW : 0) ); // 겨울 - operationalStatusList[0].winterAll = data.winter === null ? '' : ( + operationalStatusList[0].winterAll = data.winter === null ? '' : ( // 동절기 전기간 (data.winter.AVG_INFL_AMNT ? data.winter.AVG_INFL_AMNT : 0) + (data.winter.AVG_SUM_FLOW ? data.winter.AVG_SUM_FLOW : 0) + (data.winter.AVG_TP_INFL_WQLT_TP ? data.winter.AVG_TP_INFL_WQLT_TP : 0) ); - operationalStatusList[0].winterSunnyMin = data.winterSunny === null ? '' : ( + operationalStatusList[0].winterSunnyMin = data.winterSunny === null ? '' : (// 동절기 청천 최소 (data.winterSunny.MIN_INFL_AMNT ? data.winterSunny.MIN_INFL_AMNT : 0) + (data.winterSunny.MIN_SUM_FLOW ? data.winterSunny.MIN_SUM_FLOW : 0) + (data.winterSunny.MIN_TP_INFL_WQLT_TP ? data.winterSunny.MIN_TP_INFL_WQLT_TP : 0) ); - operationalStatusList[0].winterSunnyAvg = data.winterSunny === null ? '' : ( + operationalStatusList[0].winterSunnyAvg = data.winterSunny === null ? '' : (// 동절기 청천 평균 (data.winterSunny.AVG_INFL_AMNT ? data.winterSunny.AVG_INFL_AMNT : 0) + (data.winterSunny.AVG_SUM_FLOW ? data.winterSunny.AVG_SUM_FLOW : 0) + (data.winterSunny.AVG_TP_INFL_WQLT_TP ? data.winterSunny.AVG_TP_INFL_WQLT_TP : 0) ); - operationalStatusList[0].winterSunnyMax = data.winterSunny === null ? '' : ( + operationalStatusList[0].winterSunnyMax = data.winterSunny === null ? '' : (// 동절기 청천 최대 (data.winterSunny.MAX_INFL_AMNT ? data.winterSunny.MAX_INFL_AMNT : 0) + (data.winterSunny.MAX_SUM_FLOW ? data.winterSunny.MAX_SUM_FLOW : 0) + (data.winterSunny.MAX_TP_INFL_WQLT_TP ? data.winterSunny.MAX_TP_INFL_WQLT_TP : 0) @@ -1547,7 +1523,7 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) // 연계처리수,지하수유입량 const bscFctCd = fcltyCd; - const res2 = await fetchFlowRateModalList(bscFctCd, searchYear); + const res2 = await fetchFlowRateModalList(bscFctCd, searchYear); // /fclt/flow/ yearDay = res2.data?res2.data.length:1; res2.data.forEach((item: any) => { yearSumFlow += item.sumFlow && item.sumFlow !== 'x' ? Number(item.sumFlow.toString().trim()) : 0; @@ -1909,10 +1885,10 @@ export const dashBoardReducer:(set:(fn: (state: DashBoardType) => DashBoardType) const rtnData = await calcWaterQltyData(operationalStatusList, searchYear, fcltyCd); // 총인설비가 없을 경우 제외 - console.log('총인시설 여부 : ', data.yearData.TP_FCT_YN); + console.log('총인시설 여부 : ', data.yearData?.TP_FCT_YN); console.log('rtnData : ', rtnData); - if(data.yearData.TP_FCT_YN !== 'Y') { + if(data.yearData?.TP_FCT_YN !== 'Y') { operationalStatusList = rtnData.slice(0, 11); }else{ operationalStatusList = rtnData; diff --git a/apps/fo/app/store/facilityPrecipitation/index.tsx b/apps/fo/app/store/facilityPrecipitation/index.tsx index 39bc35220..6272ef55b 100644 --- a/apps/fo/app/store/facilityPrecipitation/index.tsx +++ b/apps/fo/app/store/facilityPrecipitation/index.tsx @@ -165,14 +165,12 @@ const fetchFacilityPrecipitationList = async (pageNo: number, pageSize: number, }); const rainDetailListRes: Record = {}; - const promises: Promise[] = []; + const results: any[] = []; for (let idx = 0; idx < fcltCdList.length; idx += 100) { - const slicedItems = fcltCdList.slice(idx, idx + 100); - // 각 fetch 호출을 Promise 배열에 담기 - promises.push(fetchFacilityRainDetailList(searchYear, slicedItems)); + const slicedItems = fcltCdList.slice(idx, idx + 100); + const result = await fetchFacilityRainDetailList(searchYear, slicedItems); + results.push(result); } - // 모든 요청을 병렬로 실행 - const results = await Promise.all(promises); //console.log(results); // 결과를 합치기 for (const res of results) { diff --git a/apps/fo/app/store/processArea/index.tsx b/apps/fo/app/store/processArea/index.tsx index 8a8b3075e..959b83794 100644 --- a/apps/fo/app/store/processArea/index.tsx +++ b/apps/fo/app/store/processArea/index.tsx @@ -132,32 +132,32 @@ const upHeadList1:TableUpperProps[] = [ { id: "A", title: "행정구역", colSpan:2, rowSpan:2, row:0 }, { id: "ctpvNm", title: "시도", upName:"upChangeRe", row:2, widthPercent:4 }, { id: "sggNm", title: "시군구", upName:"upChangeRe", row:2, widthPercent:4 }, - { id: "swtrmtFcltNm", title: "시설명", rowSpan:3, row:0, widthPercent: 8 }, - { id: "location", title: "주소", rowSpan:3, row:0 }, + { id: "swtrmtFcltNm", title: "시설명", rowSpan:3, row:0, widthPercent: 6 }, + { id: "location", title: "주소", rowSpan:3, row:0, widthPercent: 12 }, { id: "fctCpc", title: "시설용량(m³/일)", align:"right", rowSpan:3, row:0, widthPercent: 5 }, { id: "B", title: "시설계획", colSpan:19, row: 0 }, { id: "C", title: "증설계획(m³/일)", colSpan:6, row: 1 }, - { id: "planSwageQntSumDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "planSwageQntSum", title: "현재", row:2, align:"right", widthPercent: 2.5 }, - { id: "planSwageQntSumDc1", title: "1단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "planSwageQntSumDc2", title: "2단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "planSwageQntSumDc3", title: "3단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "planSwageQntSumDc4", title: "4단계", row:2, align:"right", widthPercent: 2.5 }, + { id: "planSwageQntSumDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "planSwageQntSum", title: "현재", row:2, align:"right", widthPercent: 3.5 }, + { id: "planSwageQntSumDc1", title: "1단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "planSwageQntSumDc2", title: "2단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "planSwageQntSumDc3", title: "3단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "planSwageQntSumDc4", title: "4단계", row:2, align:"right", widthPercent: 3.5 }, { id: "D", title: "처리인구(인)", colSpan:6, row:1 }, - { id: "swtrmtPpltnCntDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtPpltnCnt", title: "현재", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtPpltnCntDc1", title: "1단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtPpltnCntDc2", title: "2단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtPpltnCntDc3", title: "3단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtPpltnCntDc4", title: "4단계", row:2, align:"right", widthPercent: 2.5 }, + { id: "swtrmtPpltnCntDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtPpltnCnt", title: "현재", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtPpltnCntDc1", title: "1단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtPpltnCntDc2", title: "2단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtPpltnCntDc3", title: "3단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtPpltnCntDc4", title: "4단계", row:2, align:"right", widthPercent: 3.5 }, { id: "E", title: "처리구역(km²)", colSpan:7, row:1 }, - { id: "swtrmtZoneAreaDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtZoneArea", title: "현재", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtZoneAreaDc1", title: "1단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtZoneAreaDc2", title: "2단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtZoneAreaDc3", title: "3단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "swtrmtZoneAreaDc4", title: "4단계", row:2, align:"right", widthPercent: 2.5 }, - { id: "trtDstr", title: "처리구역", row:2 } + { id: "swtrmtZoneAreaDt4", title: "기승인\n4단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtZoneArea", title: "현재", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtZoneAreaDc1", title: "1단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtZoneAreaDc2", title: "2단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtZoneAreaDc3", title: "3단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "swtrmtZoneAreaDc4", title: "4단계", row:2, align:"right", widthPercent: 3.5 }, + { id: "trtDstr", title: "처리구역", row:2} ]; const processAreaList:ProcessAreaProps[] = [ diff --git a/apps/fo/app/store/sewageOccur/index.tsx b/apps/fo/app/store/sewageOccur/index.tsx index 7e58f83fe..69d2a4f50 100644 --- a/apps/fo/app/store/sewageOccur/index.tsx +++ b/apps/fo/app/store/sewageOccur/index.tsx @@ -1,5 +1,5 @@ -import { getFetch } from "@/app/components/api"; -import CalcRainEffect from "@/app/components/common/CalcRainEffect"; +import { getFetch, postFetch } from "@/app/components/api"; +// import CalcRainEffect from "@/app/components/common/CalcRainEffect"; import { roundNumber } from "@/app/components/common/DataForm"; import { OSU_PERCENT } from "../common"; import type { TableUpperProps } from "../dashboard"; @@ -34,12 +34,20 @@ export type SewageOccurProps = { } export type modalTableType = { msrDt: string,waterUsage: number,swageChangePercent: number,estimationSewage: number}; export type SewageOccurDetailProps = { - cnt : number, +cnt : number, sum : number, yr:string, - usege:number + usage:number, // 일평균 유입량 + cntWtrUsg : number, // 물사용량 일자수 + sumWtrUsg : number, // 물사용량 합 + avgWtrUsg:number, // 일평균 물사용량 + monWtrUsg:number, // 월 물사용량 +} +export type WtrUsgYmProps = { + bscFctCd : string, // 처리구역시설코드 + levyYm : string, // 부과년월 + wtrUsg : number, // 월별 물사용량(m³/월) } - // 검색단 const LabelArray:string[] = ['구분', '시도', '시군구','용량별', '시설명', '조회기간', '시설상태', '권역', '단위유역', '중권역', '환경청별']; @@ -121,6 +129,27 @@ const sewageOccurList:SewageOccurProps[] = [] const tempSewageOccurList:modalTableType[] = [] +/** + * yyyymm 형태의 문자열을 받아 해당 월의 일수를 반환 + * @param {string} yyyymm - 예: "202502", "202412" + * @returns {number} 해당 월의 일수 (예: 28, 29, 30, 31) + */ +function getDaysInMonth(yyyymm) { + if (typeof yyyymm !== 'string' || yyyymm.length !== 6) { + throw new Error("입력은 'yyyymm' 형식의 문자열이어야 합니다. 예: '202502'"); + } + + const year = parseInt(yyyymm.slice(0, 4), 10); // "2025" -> 2025 + const month = parseInt(yyyymm.slice(4, 6), 10); // "02" -> 2 + + if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { + throw new Error("유효한 년/월이 아닙니다."); + } + + // Date(year, month, 0) -> 전달의 마지막 날 + // month에 '다음달'을 넣고, 일(day)에 0을 주면 해당 월의 마지막 날짜가 됨 + return new Date(year, month, 0).getDate(); +} /**==================================== * Store - API ====================================*/ @@ -138,18 +167,39 @@ const tempSewageOccurList:modalTableType[] = [] return { data: res.data, count: res.count } }*/ -const fetchSweageOccurListFlow = async(fcltyCd : string, searchYear : string) => { - const url = '/fclt/flow/' + fcltyCd + '/' + searchYear; - const res = await getFetch('fo', url); - return res; -} +// const fetchSweageOccurListFlow = async(fcltyCd : string, searchYear : string) => { +// const url = '/fclt/flow/' + fcltyCd + '/' + searchYear; +// const res = await getFetch('fo', url); +// return res; +// } + +// 시설들 유량 정보 조회 +export const fetchSweageOccurListFlow = async (searchYear: string|undefined, fcltCdList: string[]): Promise => { + try { + const res = await postFetch('fo', '/fclt/flow/'+searchYear, fcltCdList); + return res; + } catch (error) { + console.error('시설정보 오수발생량 상세 데이터를 가져오는데 실패했습니다:', error); + return null; + } +}; +// 시설별 물사용량 정보 조회 +export const fetchWtrUsgYmList = async (searchYear: string|undefined, fcltCdList: string[]): Promise => { + try { + const res = await postFetch('fo', '/fclt/flow/wtrUsgYm/'+searchYear, fcltCdList); + return res; + } catch (error) { + console.error('시설들 월별 물사용량 데이터를 가져오는데 실패했습니다:', error); + return null; + } +}; /**==================================== * Store - expaort ====================================*/ export type SewageOccurActions = { searchSewageOccurList: (pageNo: number, pageSize: number, searchYear: string, part?: string, nameCd?: string, fcltCpct?: string, operationValue?: string, fcltyNm?: string) => void; - searchSewageOccurDetailList: (bscFctCd: string, searchYear: string, startYearMonth?: string, endYearMonth?: string) => void; + searchSewageOccurDetailList: (bscFctCd: string, searchYear: string, startYm?: string, endYm?: string) => void; } export type SewageOccurType = { @@ -201,15 +251,15 @@ export const sewageOccurInitState: SewageOccurType = { } // 병렬 처리 제한용 헬퍼 -async function processInBatches(items, batchSize, callback) { - const results:any = []; - for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize); - const batchResults = await Promise.all(batch.map(callback)); - results.push(...batchResults); - } - return results; -} +// async function processInBatches(items, batchSize, callback) { +// const results:any = []; +// for (let i = 0; i < items.length; i += batchSize) { +// const batch = items.slice(i, i + batchSize); +// const batchResults = await Promise.all(batch.map(callback)); +// results.push(...batchResults); +// } +// return results; +// } export type SewageOccurStore = SewageOccurType & SewageOccurActions; @@ -253,53 +303,94 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => { })); } - const fcltylist = facilityInitData.data; - - const calData = await processInBatches(fcltylist, 20, async (item: SewageOccurProps, index: number) => { - const bscFctCd = item.bscFctCd; - const res = await fetchSweageOccurListFlow(bscFctCd, searchYear).catch(() => { - console.log('Error fetching flow data for facility code:', bscFctCd); - return { data: [], count: 0 }; + const fcltylist = facilityInitData.data; // 시설목록 + const fcltCdList : string[] = []; // 시설코드목록 + const sewageOccurPropsList : SewageOccurProps[] = []; // 오수발생량시설목록 + fcltylist.forEach(item => { + fcltCdList.push(item.bscFctCd); + sewageOccurPropsList.push({ + bscFctCd : item.bscFctCd, + sidoNm : item.sido, + sggNm : item.gugun, + lclgvrnmntNm : '', + bscFctNm : item.bscFctNm, + daddr : item.daddr, + fctCpc : parseFloat(item.fctCpc.toString().replace(/,/g, '')), + waterUsage : 0, + swageChangePercent : 0, + estimationSewage : 0, + sewageInputYear : 0, + sewageInputChungchun : 0, + rgnNm : item.rgnNm, + rvbsn : item.rvbsn, + lgbsn : item.lgbsn, + mdmbsn : item.mdmbsn, + msrDt : '', }); + }); + + const soccurDetailRes : any = await fetchSweageOccurListFlow(searchYear, fcltCdList); + const wtrUsgYmRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList); + // const wtrUsgMap : Record = {}; + // wtrUsgRes.data.forEach(fclt => { + // wtrUsgMap[fclt.bscFctCd] = fclt.wtrUsg; + // }); + for (const item of sewageOccurPropsList) { + + const detailList = soccurDetailRes?.data[item.bscFctCd]; + const wtrUsgYmList = wtrUsgYmRes?.data[item.bscFctCd]; - item.sidoNm = item.sidoNm || ''; - item.sggNm = item.sggNm || ''; - item.fctCpc = parseFloat(item.fctCpc.toString().replace(/,/g, '')); item.swageChangePercent = 90; + let sumChungcunDay = 0; // 청천일수 + let chungcunSumFlow = 0;// 청천시 하수유입량 + let totWaterUsage = 0; // 연간 하수유입량 + let rainEffectDay = 0; + detailList?.forEach(item2 => { - let sumChungcunDay = 0; - let chungcunSumFlow = 0; - let totWaterUsage = 0; - - // 강우영향일 계산 (강우량이 3 초과이면 무조건 강우영향일) - const rainEffectData = CalcRainEffect(res.data); - - rainEffectData.forEach((item: FlowDataItem) => { - - if(Number(item.rainEffectDay) === 0){ - chungcunSumFlow += Number(item.inflAmnt); + if (item2.infoRnDay == null || item2.infoRnDay == 'x') item2.infoRnDay = 0; + if(item2.infoRnDay >= 3) { + rainEffectDay = 2; + }else{ + rainEffectDay--; + } + if(rainEffectDay<0){ //청천시 + chungcunSumFlow += item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0;//Number(item2.inflAmnt); sumChungcunDay += 1; } - totWaterUsage += Number(item.inflAmnt); + totWaterUsage += Number(item2.inflAmnt); + }); + + let wtrUsgYearTotal = 0; // 연간 물사용량 + let wtrUsgYearDays = 0; // 연간 물사용 일수 + wtrUsgYmList?.forEach(item3 => { + const days = getDaysInMonth(item3.levyYm); + wtrUsgYearTotal += Number(item3.wtrUsg); + wtrUsgYearDays += days; }); - const avg = res.count == 0 ? 0 : totWaterUsage /res.count; - item.waterUsage = parseFloat((avg * 0.65).toFixed(2)); - item.sewageInputYear = parseFloat(avg.toFixed(2)); - item.sewageInputChungchun = parseFloat((chungcunSumFlow===0?0:(chungcunSumFlow / sumChungcunDay)).toFixed(2)); - item.estimationSewage = parseFloat((item.waterUsage * (item.swageChangePercent/100)).toFixed(2)); - + // console.log("totWaterUsage:", totWaterUsage); + const count = detailList ? detailList.length : 0; + const avg = count == 0 ? 0 : totWaterUsage / count; // 일평균 유입량 + item.waterUsage = parseFloat((avg * 0.65).toFixed(2)); // 물사용량 = 일평균 유입량 * 65% + if(wtrUsgYearTotal > 0 && wtrUsgYearDays > 0) { + item.waterUsage = wtrUsgYearDays>0? wtrUsgYearTotal / wtrUsgYearDays: 0; // 일평균 물사용량 + } + item.sewageInputYear = parseFloat(avg.toFixed(2)); // 연간 하수유입량(m³/일) + item.sewageInputChungchun = parseFloat((chungcunSumFlow===0?0:(chungcunSumFlow / sumChungcunDay)).toFixed(2)); // 청천시 하수유입량 + item.estimationSewage = parseFloat((item.waterUsage * OSU_PERCENT).toFixed(2)); // 오수발생량 추정(m³/일) + if ( item.bscFctCd === '11200PB001R' ) { + console.log('chungcunSumFlow:', chungcunSumFlow); + console.log('sumChungcunDay:', sumChungcunDay); + } // 반올림 처리 ["fctCpc", "waterUsage", "swageChangePercent", "estimationSewage", "sewageInputYear", "sewageInputChungchun"].forEach( (key) => (item[key] = roundNumber(item[key], 0)) ); - - return {...item, index:index}; - }); + } if(pageSize >= 5000){ const excelDownList: ExcelType[] = []; - calData.forEach((item: SewageOccurProps) => { + sewageOccurPropsList.forEach((item: SewageOccurProps) => { excelDownList.push({ 시설코드: item.bscFctCd, 권역: item.rvbsn, @@ -331,7 +422,7 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => { ...state, SewageOccur: { ...state.SewageOccur, - sewageOccurList : calData, + sewageOccurList : sewageOccurPropsList, sewageOccurCount : facilityInitData.count, isLoading: false, excelList: [], @@ -423,24 +514,39 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => { }); */ }, - searchSewageOccurDetailList: async (bscFctCd: string, searchYear: string, startYearMonth?: string, endYearMonth?: string) => { + searchSewageOccurDetailList: async (bscFctCd: string, searchYear: string, startYm?: string, endYm?: string) => { + const fcltCdList = [bscFctCd]; + const wtrUsgRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList); const res = await getFetch('fo', '/fclt/flow/' + bscFctCd + '/' + searchYear); const monthData: SewageOccurDetailProps[] = []; let tot = 0, cnt= 0; - if(startYearMonth) { + if(startYm) { res.data = res.data.filter((item: FlowDataItem) => { const month = item.msrDt?.substring(0, 6); - return month >= startYearMonth; + return month >= startYm; }); + if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) { + wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => { + const month = item.levyYm?.substring(0, 6); + return month >= startYm; + }); + } } - if(endYearMonth) { + if(endYm) { res.data = res.data.filter((item: FlowDataItem) => { const month = item.msrDt?.substring(0, 6); - return month <= endYearMonth; + return month <= endYm; }); + if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) { + wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => { + const month = item.levyYm?.substring(0, 6); + return month <= endYm; + }); + } } + // 유입량 res.data.forEach((item: FlowDataItem) => { const month = item.msrDt?.substring(4, 6); const monthIdx = month ? Number(month) - 1 : -1; @@ -449,12 +555,31 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => { monthData[monthIdx].sum += Number(item.inflAmnt); monthData[monthIdx].cnt++; } else { - monthData[monthIdx] = {cnt:1, sum:Number(item.inflAmnt), yr:item.msrDt.substring(0,4), usege:0}; + monthData[monthIdx] = {cnt:1, sum:Number(item.inflAmnt), yr:item.msrDt.substring(0,4) + , usage:0, cntWtrUsg:0, sumWtrUsg:0, avgWtrUsg:0, monWtrUsg:0}; } } tot += Number(item.inflAmnt) cnt++ - }) + }); + + // 물사용량 + let totWtrUsg = 0; + let cntWtrUsg = 0; + if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) { + wtrUsgRes.data.forEach((item: WtrUsgYmProps) => { + const month = item.levyYm?.substring(4, 6); + const monthIdx = month ? Number(month) - 1 : -1; + if (monthIdx >= 0) { + if (typeof monthData[monthIdx] === 'object') { + monthData[monthIdx].sumWtrUsg += Number(item.wtrUsg); + monthData[monthIdx].cntWtrUsg += getDaysInMonth(item.levyYm); + } + } + totWtrUsg += Number(item.wtrUsg); + cntWtrUsg += getDaysInMonth(item.levyYm); + }); + } const tableData:modalTableType[] = [] @@ -462,42 +587,44 @@ export const sewageOccurReducer:(set:any)=>SewageOccurActions=(set: any) => { let minWaterUsage = 9999999999; let avgWaterUsage = 0; monthData.forEach((item, index) => { - item.usege = item.sum/item.cnt *0.65 + item.avgWtrUsg = item.sum/item.cnt *0.65; // 일평균 물사용량 = 일평균유입수 * 0.65 + if (item.sumWtrUsg > 0 && item.cntWtrUsg > 0) item.avgWtrUsg = item.sumWtrUsg / item.cntWtrUsg; tableData.push({ msrDt: item.yr+'-'+(index+1>=10?String(index+1):'0'+(index+1)), - waterUsage: Math.round(item.usege*1) /1, - swageChangePercent: (Math.round((item.usege/item.usege*OSU_PERCENT) * 100) / 100) * 100, - estimationSewage: Math.round(item.usege*OSU_PERCENT * 1) / 1 + waterUsage: Math.round(item.avgWtrUsg*1) /1, + swageChangePercent: OSU_PERCENT*100,//(Math.round((item.usage/item.usage*OSU_PERCENT) * 100) / 100) * 100, + estimationSewage: Math.round(item.avgWtrUsg*OSU_PERCENT * 1) / 1 }) - if (item.usege > maxWaterUsage) { - maxWaterUsage = item.usege; + if (item.avgWtrUsg > maxWaterUsage) { + maxWaterUsage = item.avgWtrUsg; } - if (item.usege < minWaterUsage) { - minWaterUsage = item.usege; + if (item.avgWtrUsg < minWaterUsage) { + minWaterUsage = item.avgWtrUsg; } }); avgWaterUsage = tot/cnt*0.65 + if (totWtrUsg > 0 && cntWtrUsg > 0) avgWaterUsage = totWtrUsg / cntWtrUsg; const newData= [ { msrDt: '평균', - waterUsage: Math.round(avgWaterUsage*1) /1, - swageChangePercent: 90, - estimationSewage: Math.round(avgWaterUsage*0.9*1) /1, + waterUsage: roundNumber(Math.round(avgWaterUsage*1) /1,0), + swageChangePercent: OSU_PERCENT*100, + estimationSewage: roundNumber(Math.round(avgWaterUsage*OSU_PERCENT*1) /1,0), }, { msrDt: '최대', - waterUsage: Math.round(maxWaterUsage*100) /100, - swageChangePercent: 90, - estimationSewage: Math.round(maxWaterUsage*0.9*1) /1, + waterUsage: roundNumber(Math.round(maxWaterUsage*100) /100,0), + swageChangePercent: OSU_PERCENT*100, + estimationSewage: roundNumber(Math.round(maxWaterUsage*OSU_PERCENT*1) /1, 0), }, { msrDt: '최소', - waterUsage: Math.round(minWaterUsage*100) /100, - swageChangePercent: 90, - estimationSewage: Math.round(minWaterUsage*0.9*1) /1, + waterUsage: roundNumber(Math.round(minWaterUsage*100) /100,0), + swageChangePercent: OSU_PERCENT*100, + estimationSewage: roundNumber(Math.round(minWaterUsage*OSU_PERCENT*1) /1,0), }, ...tableData ] diff --git a/apps/fo/app/store/underWaterInput/index.tsx b/apps/fo/app/store/underWaterInput/index.tsx index 0624886e9..c6a2167d8 100644 --- a/apps/fo/app/store/underWaterInput/index.tsx +++ b/apps/fo/app/store/underWaterInput/index.tsx @@ -1,8 +1,8 @@ -import { getFetch } from "@/app/components/api"; +import { getFetch, postFetch } from "@/app/components/api"; import DataForm, { roundNumber } from "@/app/components/common/DataForm"; import type { TableUpperProps } from "../dashboard"; -import type { ExcelType, UnderWaterInputModalProps, UnderWaterProps } from "./types"; -import CalcRainEffect from "@/app/components/common/CalcRainEffect"; +import type { ExcelType, UnderWaterInputModalProps, UnderWaterProps, WtrUsgYmProps } from "./types"; +// import CalcRainEffect from "@/app/components/common/CalcRainEffect"; import { OSU_PERCENT } from "../common"; import { fetchFacilityListInit } from "../processFacility"; export type { TableUpperProps }; @@ -12,7 +12,13 @@ export type SewageOccurDetailProps = { cnt : number, sum : number, yr:string, - usege:number + usage:number, // 일평균 유입량 + sunnyCnt : number, + sunnyInflAmnt : number, + sunnyAvgInflAmnt : number, // 청천시 일평균 유입량 + cntWtrUsg : number, // 물사용량 일자수 + sumWtrUsg : number, // 물사용량 합 + avgWtrUsg:number, // 일평균 물사용량 } /**==================================== * Store - 타입 @@ -203,11 +209,32 @@ const fetchUnderWaterList = async (pageNo: number, pageSize: number, searchYear: //return { data: underWaterList, count: 2 }; }*/ -const fetchUnderWaterListFlow = async(fcltyCd : string, searchYear : string) => { - const url = '/fclt/flow/' + fcltyCd + '/' + searchYear; - const res = await getFetch('fo', url); - return res; -} +// const fetchUnderWaterListFlow = async(fcltyCd : string, searchYear : string) => { +// const url = '/fclt/flow/' + fcltyCd + '/' + searchYear; +// const res = await getFetch('fo', url); +// return res; +// } + +// 시설들 유량 정보 조회 +export const fetchDetailListFlow = async (searchYear: string|undefined, fcltCdList: string[]): Promise => { + try { + const res = await postFetch('fo', '/fclt/flow/'+searchYear, fcltCdList); + return res; + } catch (error) { + console.error('시설정보 오수발생량 상세 데이터를 가져오는데 실패했습니다:', error); + return null; + } +}; +// 시설들 월별 물사용량 정보 조회 +export const fetchWtrUsgYmList = async (searchYear: string|undefined, fcltCdList: string[]): Promise => { + try { + const res = await postFetch('fo', '/fclt/flow/wtrUsgYm/'+searchYear, fcltCdList); + return res; + } catch (error) { + console.error('시설들 월별 물사용량 데이터를 가져오는데 실패했습니다:', error); + return null; + } +}; /**==================================== * Store - expaort @@ -274,14 +301,35 @@ const dataFormSetting=(item : UnderWaterProps)=> { } // 병렬 처리 제한용 헬퍼 -async function processInBatches(items, batchSize, callback) { - const results :any = []; - for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize); - const batchResults = await Promise.all(batch.map(callback)); - results.push(...batchResults); +// async function processInBatches(items, batchSize, callback) { +// const results :any = []; +// for (let i = 0; i < items.length; i += batchSize) { +// const batch = items.slice(i, i + batchSize); +// const batchResults = await Promise.all(batch.map(callback)); +// results.push(...batchResults); +// } +// return results; +// } +/** + * yyyymm 형태의 문자열을 받아 해당 월의 일수를 반환 + * @param {string} yyyymm - 예: "202502", "202412" + * @returns {number} 해당 월의 일수 (예: 28, 29, 30, 31) + */ +function getDaysInMonth(yyyymm) { + if (typeof yyyymm !== 'string' || yyyymm.length !== 6) { + throw new Error("입력은 'yyyymm' 형식의 문자열이어야 합니다. 예: '202502'"); } - return results; + + const year = parseInt(yyyymm.slice(0, 4), 10); // "2025" -> 2025 + const month = parseInt(yyyymm.slice(4, 6), 10); // "02" -> 2 + + if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { + throw new Error("유효한 년/월이 아닙니다."); + } + + // Date(year, month, 0) -> 전달의 마지막 날 + // month에 '다음달'을 넣고, 일(day)에 0을 주면 해당 월의 마지막 날짜가 됨 + return new Date(year, month, 0).getDate(); } export type UnderWaterStore = UnderWaterType & UnderWaterActions; @@ -313,68 +361,152 @@ export const underWaterReducer:(set:(fn: (state: UnderWaterType) => UnderWaterTy } const fcltylist = facilityInitData.data; - - const calData = await processInBatches(fcltylist, 20, async (item: any, index: number) => { - const bscFctCd = item.bscFctCd; - const res = await fetchUnderWaterListFlow(bscFctCd, searchYear).catch((error) => { - console.log('Error fetching flow data for facility code:', bscFctCd, error); - return { data: [], count: 0 }; // 에러 발생 시 빈 데이터 반환 + const fcltCdList : string[] = []; // 시설코드목록 + const underWaterPropsList : UnderWaterProps[] = []; // 오수발생량시설목록 + fcltylist.forEach(item => { + fcltCdList.push(item.bscFctCd); + underWaterPropsList.push({ + bscFctCd : item.bscFctCd, + sidoNm : item.sido, + sggNm : item.gugun, + lclgvrnmntNm : '', + bscFctNm : item.bscFctNm, + daddr : item.daddr, + fctCpc : parseFloat(item.fctCpc.toString().replace(/,/g, '')), + inflAmnt : 0, + waterUsage : 0, + underWaterInputYear : 0, + underWaterInputSummer : 0, + underWaterInputWinter : 0, + properYn : '', + infoRnDay : 0, + rgnNm : item.rgnNm, + rvbsn : item.rvbsn, + lgbsn : item.lgbsn, + mdmbsn : item.mdmbsn, + msrDt : '', }); - - item.sidoNm = item.sido || ''; - item.sggNm = item.gugun || ''; - item.fctCpc = parseFloat(item.fctCpc.toString().replace(/,/g, '')); + }); + + const soccurDetailRes : any = await fetchDetailListFlow(searchYear, fcltCdList); + const wtrUsgYmRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList); + + for (const item of underWaterPropsList) { - let chungcunSumFlow = 0; + const detailList = soccurDetailRes?.data[item.bscFctCd]; + const wtrUsgYmList = wtrUsgYmRes?.data[item.bscFctCd]; + + let chungcunSumFlow = 0; // 청천유입량 + let sumChungcunDay = 0; // 청천일수 + let sumSummerChungcunDay = 0; // 하절기 청천일수 + let sumSummerChungcunFlow = 0; // 하절기 청천유입량 + let sumWinterChungcunDay = 0; // 동절기 청천일수 + let sumWinterChungcunFlow = 0; // 동절기 청천유입량 + let totWaterUsage = 0; // 연간 하수유입량 + let totSummerUsage = 0; // 하절기 하수유입량 + let totWinterUsage = 0; // 동절기 하수유입량 + let totSummerDays = 0; // 하절기 일수 + let totWinterDays = 0; // 동절기 일수 + + const summerMonth = ['06', '07', '08']; + const winterMonth = ['01', '02']; + + let rainEffectDay = 0; + detailList?.forEach(item2 => { - let sumSummerChungcunDay = 0; - let sumSummerChungcunFlow = 0; + if (item2.infoRnDay == null || item2.infoRnDay.trim() == 'x') item2.infoRnDay = 0; + if(Number(item2.infoRnDay) >= 3) { + rainEffectDay = 2; + }else{ + rainEffectDay--; + } + if(rainEffectDay<0){ //청천시 + chungcunSumFlow += Number(item2.inflAmnt); + sumChungcunDay += 1; - let sumWinterChungcunDay = 0; - let sumWinterChungcunFlow = 0; - - const rainEffectData = CalcRainEffect(res.data, 'SUNNY'); - rainEffectData.forEach((flowItem: FlowDataItem) => { - chungcunSumFlow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0; - - if(flowItem.msrDt.substring(4,6) ==='06' || flowItem.msrDt.substring(4,6) ==='07' || flowItem.msrDt.substring(4,6) ==='08'){ - sumSummerChungcunDay += 1; - sumSummerChungcunFlow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0; - }else if(flowItem.msrDt.substring(4,6) ==='01' || flowItem.msrDt.substring(4,6) ==='02'){ - sumWinterChungcunDay += 1; - sumWinterChungcunFlow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0; + if(item2.msrDt.substring(4,6) ==='06' || item2.msrDt.substring(4,6) ==='07' || item2.msrDt.substring(4,6) ==='08'){ + // 하절기 6,7,8월 + sumSummerChungcunDay += 1; + sumSummerChungcunFlow += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0; + }else if(item2.msrDt.substring(4,6) ==='01' || item2.msrDt.substring(4,6) ==='02'){ + // 동절기 1,2월 + sumWinterChungcunDay += 1; + sumWinterChungcunFlow += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0; + } + } + totWaterUsage += Number(item2.inflAmnt); + if(item2.msrDt.substring(4,6) ==='06' || item2.msrDt.substring(4,6) ==='07' || item2.msrDt.substring(4,6) ==='08'){ + // 하절기 6,7,8월 + totSummerDays += 1; + totSummerUsage += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0; + }else if(item2.msrDt.substring(4,6) ==='01' || item2.msrDt.substring(4,6) ==='02'){ + // 동절기 1,2월 + totWinterDays += 1; + totWinterUsage += Number(item2.inflAmnt); //item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0; } }); - let totWaterInflow = 0 - - const rainEffectTot = CalcRainEffect(res.data); - rainEffectTot.forEach((flowItem: FlowDataItem) => { - totWaterInflow += flowItem.inflAmnt && flowItem.inflAmnt !== 'x' ? Number(flowItem.inflAmnt.toString().trim()) : 0; + const count = detailList ? detailList.length : 0; + const avg = count == 0 ? 0 : totWaterUsage / count; // 일평균 유입량 + + let wtrUsgYearTotal = 0; // 연간 물사용량 + let summerWtrUsgTot = 0; // 하절기 물사용량 + let winterWtrUsgTot = 0; // 동절기 물사용량 + let wtrUsgYearDays = 0; // 연간 물사용 일수 + let summerWtrUsgDays = 0;// 하절기 물사용 일수 + let winterWtrUsgDays = 0;// 동절기 물사용 일수 + wtrUsgYmList?.forEach(item3 => { + const days = getDaysInMonth(item3.levyYm); + wtrUsgYearTotal += Number(item3.wtrUsg); + wtrUsgYearDays += days; + if(summerMonth.includes(item3.levyYm.slice(-2))){ + summerWtrUsgTot += Number(item3.wtrUsg); + summerWtrUsgDays += days; + }else if(winterMonth.includes(item3.levyYm.slice(-2))){ + winterWtrUsgTot += Number(item3.wtrUsg); + winterWtrUsgDays += days; + } }); - - const avg = res.count === 0 ? 0 : totWaterInflow / res.count; - - const usage = chungcunSumFlow&&chungcunSumFlow>0?chungcunSumFlow / rainEffectData.length:0; - const summerUsage = sumSummerChungcunFlow?sumSummerChungcunFlow / sumSummerChungcunDay:0; - const winterUsage = sumWinterChungcunFlow?sumWinterChungcunFlow / sumWinterChungcunDay:0; - - item.inflAmnt = roundNumber(avg, 0); - item.underWaterInputYear = Math.round((usage-(usage*0.65*OSU_PERCENT)) * 1) / 1; - item.underWaterInputSummer = Math.round((summerUsage-(summerUsage*0.65*OSU_PERCENT)) * 1) / 1; - item.underWaterInputWinter = Math.round((winterUsage-(winterUsage*0.65*OSU_PERCENT)) * 1) / 1; - item.properYn = Math.round( (Number(usage-(usage*0.65*OSU_PERCENT)) / Number(item.inflAmnt)) * 100 * 1 ) / 1 > 20?'초과유입':'적정'; - return {...item, index:index}; - }); + if (wtrUsgYearTotal > 0) { // 물사용량이 있는 경우 + const usage = chungcunSumFlow&&chungcunSumFlow>0?chungcunSumFlow / sumChungcunDay:0; // 청천시 일평균 유입량 + const summerUsage = sumSummerChungcunFlow?sumSummerChungcunFlow / sumSummerChungcunDay:0; // 하절기 청천시 일평균 유입량 + const winterUsage = sumWinterChungcunFlow?sumWinterChungcunFlow / sumWinterChungcunDay:0; // 동절기 청천시 일평균 유입량 + const wtrUsg = wtrUsgYearDays>0? wtrUsgYearTotal / wtrUsgYearDays: 0; // 일평균 물사용량 + const summerWtrUsg = summerWtrUsgTot?summerWtrUsgTot / summerWtrUsgDays:0; // 하절기 일평균 물사용량 + const winterWtrUsg = winterWtrUsgTot?winterWtrUsgTot / winterWtrUsgDays:0; // 동절기 일평균 물사용량 + + item.inflAmnt = roundNumber(avg, 0); + item.underWaterInputYear = Math.round((usage-(wtrUsg*OSU_PERCENT)) * 1) / 1; // 연간지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율) + item.underWaterInputSummer = Math.round((summerUsage-(summerWtrUsg*OSU_PERCENT)) * 1) / 1; // 하절기지하수유입량(m³/일) + item.underWaterInputWinter = Math.round((winterUsage-(winterWtrUsg*OSU_PERCENT)) * 1) / 1; // 동절기지하수유입량(m³/일) + item.properYn = Math.round( (Number(usage-(wtrUsg*OSU_PERCENT)) / Number(item.inflAmnt)) * 100 * 1 ) / 1 > 20?'초과유입':'적정'; // 적정여부 + } else { // 물사용량이 없는 경우, 일평균 유입량으로 물사용량 추정(물사용량 = 유입량*0.65) + const usage = chungcunSumFlow&&chungcunSumFlow>0?chungcunSumFlow / sumChungcunDay:0; // 청천시 일평균 유입량 + const summerUsage = sumSummerChungcunFlow?sumSummerChungcunFlow / sumSummerChungcunDay:0; // 하절기 청천시 일평균 유입량 + const winterUsage = sumWinterChungcunFlow?sumWinterChungcunFlow / sumWinterChungcunDay:0; // 동절기 청천시 일평균 유입량 + const wtrUsg = avg * 0.65; // 일평균 물사용량 + const summerWtrUsg = totSummerDays>0? (totSummerUsage / totSummerDays)*0.65:0; // 하절기 일평균 물사용량 + const winterWtrUsg = totWinterDays>0? (totWinterUsage / totWinterDays)*0.65:0; // 동절기 일평균 물사용량 + + item.inflAmnt = roundNumber(avg, 0); + //item.underWaterInputYear = Math.round((usage-(usage*0.65*OSU_PERCENT)) * 1) / 1; // 연간지하수유입량(m³/일) + item.underWaterInputYear = Math.round((usage-(wtrUsg*OSU_PERCENT)) * 1) / 1; // 연간지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율) + item.underWaterInputSummer = Math.round((summerUsage-(summerWtrUsg*OSU_PERCENT)) * 1) / 1; // 하절기지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율) + item.underWaterInputWinter = Math.round((winterUsage-(winterWtrUsg*OSU_PERCENT)) * 1) / 1; // 동절기지하수유입량(m³/일) = 청천시 실제유입량 - (물사용량 × 오수전환율) + item.properYn = Math.round( (Number(usage-(wtrUsg*OSU_PERCENT)) / Number(item.inflAmnt)) * 100 * 1 ) / 1 > 20?'초과유입':'적정'; // 적정여부 + // 반올림 처리 + // ["fctCpc", "inflAmnt", "underWaterInputYear", "underWaterInputSummer", "underWaterInputWinter"].forEach( + // (key) => (item[key] = roundNumber(item[key], 0)) + // ); + } + } if(pageSize>= 5000){ const excelDownList: ExcelType[] = []; - calData.forEach((item : any) => { + underWaterPropsList.forEach((item : any) => { dataFormSetting(item) item.fctCpc = parseFloat(item.fctCpc.toString().replace(/,/g, '')); - /* item.sidoNm = item.lclgvrnmntNm.split(' ')[0]?.toString() || ''; - item.sggNm = item.lclgvrnmntNm.split(' ')[1]?.toString() || ''; */ excelDownList.push({ 시설코드: item.bscFctCd, 권역: item.rvbsn, @@ -406,7 +538,7 @@ export const underWaterReducer:(set:(fn: (state: UnderWaterType) => UnderWaterTy ...state, UnderWater: { ...state.UnderWater, - underWaterList: calData, + underWaterList: underWaterPropsList, underWaterCount: facilityInitData.count, excelList: [], isLoading : false, @@ -416,79 +548,148 @@ export const underWaterReducer:(set:(fn: (state: UnderWaterType) => UnderWaterTy }, unserWaterModal:async (bscFctCd, searchYear, startYm, endYm)=>{ + const fcltCdList = [bscFctCd]; + const wtrUsgRes : any = await fetchWtrUsgYmList(searchYear, fcltCdList); const res = await getFetch('fo', '/fclt/flow/' + bscFctCd + '/' + searchYear); const monthData: SewageOccurDetailProps[] = []; - - const calData = CalcRainEffect(res.data, 'SUNNY'); - let tot = 0; - let cnt = 0; - - calData.forEach((item: FlowDataItem) => { - const month = item.msrDt?.substring(4, 6); + let tot = 0, cnt = 0; + let sunnyTot = 0, sunnyCnt = 0; + + if(startYm) { + res.data = res.data.filter((item: FlowDataItem) => { + const month = item.msrDt?.substring(0, 6); + return month >= startYm; + }); + if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) { + wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => { + const month = item.levyYm?.substring(0, 6); + return month >= startYm; + }); + } + } + if(endYm) { + res.data = res.data.filter((item: FlowDataItem) => { + const month = item.msrDt?.substring(0, 6); + return month <= endYm; + }); + if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) { + wtrUsgRes.data = wtrUsgRes.data.filter((item: WtrUsgYmProps) => { + const month = item.levyYm?.substring(0, 6); + return month <= endYm; + }); + } + } + let rainEffectDay = 0; + res.data?.forEach(item2 => { + if (item2.infoRnDay == null || item2.infoRnDay.trim() == 'x') item2.infoRnDay = 0; + if(Number(item2.infoRnDay) >= 3) { + rainEffectDay = 2; + }else{ + rainEffectDay--; + } + + const month = item2.msrDt?.substring(4, 6); const monthIdx = month ? Number(month) - 1 : -1; if (monthIdx >= 0 && (monthIdx>=Number(startYm.substring(4,6))-1 && monthIdx <= Number(endYm.substring(4, 6))-1)) { if (typeof monthData[monthIdx] === 'object') { - monthData[monthIdx].sum += Number(item.inflAmnt); + monthData[monthIdx].sum += Number(item2.inflAmnt); // 유입량 합 monthData[monthIdx].cnt++; } else { - monthData[monthIdx] = {cnt:1, sum:Number(item.inflAmnt), yr:item.msrDt.substring(0,4), usege:0}; + monthData[monthIdx] = {cnt:1, sum:Number(item2.inflAmnt), yr:item2.msrDt.substring(0,4) + , usage:0, sunnyCnt:0, sunnyInflAmnt:0, sunnyAvgInflAmnt: 0, cntWtrUsg:0, sumWtrUsg:0, avgWtrUsg:0}; } - } - + } if(month >= startYm.substring(4, 6) && month <= endYm.substring(4, 6)){ cnt++; - tot += item.inflAmnt && item.inflAmnt !== 'x' ? Number(item.inflAmnt.toString().trim()) : 0; + tot += item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0; + } + if(rainEffectDay<0) { // 청천시 + if (typeof monthData[monthIdx] === 'object') { + monthData[monthIdx].sunnyInflAmnt += Number(item2.inflAmnt); // 청천 유입량 합 + monthData[monthIdx].sunnyCnt++; + }else { + monthData[monthIdx] = {cnt:1, sum:Number(item2.inflAmnt), yr:item2.msrDt.substring(0,4) + , usage:0, sunnyCnt:0, sunnyInflAmnt:0, sunnyAvgInflAmnt: 0, cntWtrUsg:0, sumWtrUsg:0, avgWtrUsg:0}; + } + if (month >= startYm.substring(4, 6) && month <= endYm.substring(4, 6)){ + sunnyCnt++; + sunnyTot += item2.inflAmnt && item2.inflAmnt !== 'x' ? Number(item2.inflAmnt.toString().trim()) : 0; + } } }); + // console.log(monthData); + // 물사용량 + let totWtrUsg = 0; + let cntWtrUsg = 0; + if (wtrUsgRes && wtrUsgRes.data && wtrUsgRes.data[bscFctCd]) { + wtrUsgRes.data.forEach((item: WtrUsgYmProps) => { + const month = item.levyYm?.substring(4, 6); + const monthIdx = month ? Number(month) - 1 : -1; + const days = getDaysInMonth(item.levyYm); + if (monthIdx >= 0) { + if (typeof monthData[monthIdx] === 'object') { + monthData[monthIdx].sumWtrUsg += Number(item.wtrUsg); + monthData[monthIdx].cntWtrUsg += days; + } + } + totWtrUsg += Number(item.wtrUsg); + cntWtrUsg += days; + }); + } + const tableData:UnderWaterInputModalProps[] = [] let maxWaterUsage = 0; let minWaterUsage = 9999999999; let avgWaterUsage = 0; + let maxSunnyInflAmnt = 0; + let minSunnyInflAmnt = 9999999999; + let avgSunnyInflAmnt = 0; monthData.forEach((item, index) => { - item.usege = item.sum/item.cnt + item.usage = item.sum/item.cnt; // 일평균 유입량 + item.avgWtrUsg = item.sum/item.cnt * 0.65; // 일평균 물사용량 + if (item.sumWtrUsg > 0 && item.cntWtrUsg > 0) item.avgWtrUsg = item.sumWtrUsg / item.cntWtrUsg; // 일평균 물사용량 + item.sunnyAvgInflAmnt = item.sunnyInflAmnt / item.sunnyCnt; // 청천 일평균 유입량 tableData.push({ msrDt: item.yr+'-'+(index+1>=10?String(index+1):'0'+(index+1)), - realSewageInput:Math.round(item.usege*1) /1, - sewageOccur:Math.round(item.usege*0.65*OSU_PERCENT * 1) / 1, - underWaterInput:Math.round((item.usege-(item.usege*0.65*OSU_PERCENT)) * 1) / 1, + realSewageInput:Math.round(item.sunnyAvgInflAmnt*1) /1, // 청천 실제 하수유입량 + sewageOccur:Math.round(item.avgWtrUsg*OSU_PERCENT * 1) / 1,// 오수발생량 + underWaterInput:Math.round((item.sunnyAvgInflAmnt-(item.avgWtrUsg*OSU_PERCENT)) * 1) / 1, // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율) }) - if (item.usege > maxWaterUsage) { - maxWaterUsage = item.usege; - } - - if (item.usege < minWaterUsage) { - minWaterUsage = item.usege; - } - + if (item.avgWtrUsg > maxWaterUsage) maxWaterUsage = item.avgWtrUsg; + if (item.sunnyAvgInflAmnt > maxSunnyInflAmnt) maxSunnyInflAmnt = item.sunnyAvgInflAmnt; + if (item.avgWtrUsg < minWaterUsage) minWaterUsage = item.avgWtrUsg; + if (item.sunnyAvgInflAmnt < minSunnyInflAmnt) minSunnyInflAmnt = item.sunnyAvgInflAmnt; }); if(monthData.length == 0){ - maxWaterUsage = 0; - avgWaterUsage = 0; - minWaterUsage = 0; + maxWaterUsage = 0; minWaterUsage = 0; avgWaterUsage = 0; + maxSunnyInflAmnt = 0; minSunnyInflAmnt = 0; avgSunnyInflAmnt = 0; } - avgWaterUsage = tot/cnt; + avgWaterUsage = cnt>0? tot/cnt * 0.65:0; // 일평균 물사용량 + if (totWtrUsg > 0 && cntWtrUsg > 0) avgWaterUsage = totWtrUsg / cntWtrUsg; // 일평균 물사용량 + avgSunnyInflAmnt = (sunnyCnt>0)?sunnyTot/sunnyCnt:0; // 청천 일평균 유입량 + const newData= [ { msrDt: '평균', - realSewageInput:Math.round(avgWaterUsage*1) /1, - sewageOccur:Math.round(avgWaterUsage*0.65*OSU_PERCENT * 1) / 1, - underWaterInput: Math.round((avgWaterUsage-(avgWaterUsage*0.65*OSU_PERCENT)) * 1) / 1 + realSewageInput:Math.round(avgSunnyInflAmnt*1) /1, // 청천시 실제 하수유입량 + sewageOccur:Math.round(avgWaterUsage*OSU_PERCENT * 1) / 1, // 오수발생량 + underWaterInput: Math.round((avgSunnyInflAmnt-(avgWaterUsage*OSU_PERCENT)) * 1) / 1 // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율) //underWaterInput:Math.round((avgWaterUsage-(avgWaterUsage*0.65*0.8)) * 100) / 100, }, { msrDt: '최대', - realSewageInput: Math.round(maxWaterUsage*1) /1, - sewageOccur: Math.round(maxWaterUsage*0.65*OSU_PERCENT * 1) / 1, - underWaterInput: Math.round((maxWaterUsage-(maxWaterUsage*0.65*OSU_PERCENT)) * 1) / 1, + realSewageInput: Math.round(maxSunnyInflAmnt*1) /1, // 청천시 실제 하수유입량 + sewageOccur: Math.round(maxWaterUsage*OSU_PERCENT * 1) / 1, // 오수발생량 + underWaterInput: Math.round((maxSunnyInflAmnt-(maxWaterUsage*OSU_PERCENT)) * 1) / 1, // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율) }, { msrDt: '최소', - realSewageInput: Math.round(minWaterUsage*1) /1, - sewageOccur: Math.round(minWaterUsage*0.65*OSU_PERCENT * 1) / 1, - underWaterInput: Math.round((minWaterUsage-(minWaterUsage*0.65*OSU_PERCENT)) * 1) / 1, + realSewageInput: Math.round(minSunnyInflAmnt*1) /1, // 청천시 실제 하수유입량 + sewageOccur: Math.round(minWaterUsage*OSU_PERCENT * 1) / 1, // 오수발생량 + underWaterInput: Math.round((minSunnyInflAmnt-(minWaterUsage*OSU_PERCENT)) * 1) / 1, // 지하수 유입량 : 하수처리장 청천시 실제유입량 - (물사용량 × 오수전환율) }, ...tableData ] diff --git a/apps/fo/app/store/underWaterInput/types.tsx b/apps/fo/app/store/underWaterInput/types.tsx index 396bd631b..85be50e8e 100644 --- a/apps/fo/app/store/underWaterInput/types.tsx +++ b/apps/fo/app/store/underWaterInput/types.tsx @@ -1,21 +1,29 @@ export type UnderWaterProps = { - bscFctCd : string, + bscFctCd : string, // 처리구역시설코드 lclgvrnmntNm : string, - sidoNm : string, - sggNm : string, - bscFctNm : string, - daddr : string, - fctCpc : number, - underWaterInputYear : number, - underWaterInputSummer : number, - underWaterInputWinter : number, + sidoNm : string, // 시도 + sggNm : string, // 시군구 + bscFctNm : string, // 시설명 + daddr : string, // 주소 + fctCpc : number, // 시설용량 + waterUsage : number, // 물사용량(m³/일) + underWaterInputYear : number, // 연간 지하수유입량 추정(m³/일) + underWaterInputSummer : number, // 하절기 지하수유입량 추정(m³/일) + underWaterInputWinter : number, // 동절기 지하수유입량 추정(m³/일) infoRnDay : number, rgnNm : string, rvbsn : string, lgbsn : string, mdmbsn : string, - inflAmnt : number | '', - properYn : string | '', + inflAmnt : number, // 연평균유입하수량(m³/일) + properYn : string | '', // 적정여부 + msrDt : string, // 측정일자 +} + +export type WtrUsgYmProps = { + bscFctCd : string, // 처리구역시설코드 + levyYm : string, // 부과년월 + wtrUsg : number, // 월별 물사용량(m³/월) } export interface UnderWaterInterface { bscFctCd : string, diff --git a/apps/fo/package.json b/apps/fo/package.json index 01b09e75c..343d463be 100644 --- a/apps/fo/package.json +++ b/apps/fo/package.json @@ -15,7 +15,7 @@ "@tailwindcss/vite": "^4.0.10", "html2canvas": "^1.4.1", "js-cookie": "^3.0.5", - "next": "15.1.7", + "next": "15.3.6", "select": "^1.1.2", "webpack": "^5.98.0", "zustand": "^5.0.3", diff --git a/apps/sn/package.json b/apps/sn/package.json index a9c3bb923..b3a6bfcf8 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -26,7 +26,7 @@ "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "moment": "^2.30.1", - "next": "15.1.7", + "next": "15.3.6", "react-chartjs-2": "^5.3.0", "react-hot-toast": "^2.5.2", "react-dnd": "^16.0.1", diff --git a/apps/wr/.env.local b/apps/wr/.env.local index a35173615..f418f679e 100644 --- a/apps/wr/.env.local +++ b/apps/wr/.env.local @@ -18,7 +18,8 @@ NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login - +NEXT_PUBLIC_HASUDOINFO = https://www.hasudoinfo.or.kr +NEXT_PUBLIC_HASUDOINFO_IN = https://www.hasudoinfo.or.kr NEXT_PUBLIC_FINDADRESS_URL = https://business.juso.go.kr/addrlink/addrLinkApi.do NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM= REACT_EDITOR = atom diff --git a/apps/wr/.env.mindone b/apps/wr/.env.mindone index 48f2b870e..4a928a910 100644 --- a/apps/wr/.env.mindone +++ b/apps/wr/.env.mindone @@ -15,6 +15,7 @@ NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/ap NEXT_PUBLIC_SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} - +NEXT_PUBLIC_HASUDOINFO = https://www.hasudoinfo.or.kr +NEXT_PUBLIC_HASUDOINFO_IN = https://www.hasudoinfo.or.kr NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM= diff --git a/apps/wr/.env.production b/apps/wr/.env.production index 2d7a96b90..27c562dee 100644 --- a/apps/wr/.env.production +++ b/apps/wr/.env.production @@ -17,6 +17,7 @@ NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:80 NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service NEXT_PUBLIC_NGII_URL = http://172.29.143.87 NEXT_PUBLIC_NGII_PARAM = BDE2BEF39DAFEC293AB6730150F222AC&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={y}&tilerow={x} - +NEXT_PUBLIC_HASUDOINFO = https://www.hasudoinfo.or.kr +NEXT_PUBLIC_HASUDOINFO_IN = http://10.101.163.102:8889 NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA= \ No newline at end of file diff --git a/apps/wr/app/(without-layout)/stats/hasuStat/page.tsx b/apps/wr/app/(without-layout)/stats/hasuStat/page.tsx index 1d2bf2eb3..34ce031e1 100644 --- a/apps/wr/app/(without-layout)/stats/hasuStat/page.tsx +++ b/apps/wr/app/(without-layout)/stats/hasuStat/page.tsx @@ -20,7 +20,7 @@ export default function HasuStatPage() { const data = await res.json(); const ip: string = data?.ip || ""; if (didCancel) return; - alert("Client IP:" + ip); + //alert("Client IP:" + ip); const is10 = /^10\./.test(ip); const is172 = /^172\./.test(ip); @@ -29,9 +29,11 @@ export default function HasuStatPage() { // Use location.replace so user can't go back to this page if (is10||is172) { // redirect to internal IP - window.open('http://10.101.163.102:8888/cms/lay1/WS10000014/program.do', '_blank'); + // alert(process.env.NEXT_PUBLIC_HASUDOINFO_IN +'/cms/lay1/WS10000014/program.do'); + window.open(process.env.NEXT_PUBLIC_HASUDOINFO_IN +'/cms/lay1/WS10000014/program.do', '_blank'); } else { - window.open('https://www.hasudoinfo.or.kr/cms/lay1/WS10000014/program.do', '_blank'); + // alert(process.env.NEXT_PUBLIC_HASUDOINFO +'/cms/lay1/WS10000014/program.do'); + window.open(process.env.NEXT_PUBLIC_HASUDOINFO +'/cms/lay1/WS10000014/program.do', '_blank'); } window.history.back(); } catch { diff --git a/apps/wr/app/auth/login/page.tsx b/apps/wr/app/auth/login/page.tsx new file mode 100644 index 000000000..0dbc42284 --- /dev/null +++ b/apps/wr/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/src/features/auth/login"; +export default LoginPage; diff --git a/apps/wr/app/auth/login/route.ts b/apps/wr/app/auth/login/route-bak.ts similarity index 100% rename from apps/wr/app/auth/login/route.ts rename to apps/wr/app/auth/login/route-bak.ts diff --git a/apps/wr/app/page.tsx b/apps/wr/app/page.tsx index fb131e794..49ca96786 100644 --- a/apps/wr/app/page.tsx +++ b/apps/wr/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { LoginApi } from "../src/entities/auth"; +// import { LoginApi } from "../src/entities/auth"; import { useEffect } from "react"; import { getCookie } from "../src/shared/lib"; import { AUTH_USER_ID } from "../src/shared/config"; @@ -16,16 +16,18 @@ export default function Home() { if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") { if (!checkToken) { - async function doLogin() { - try { - await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" }); - window.location.href = "/map/mainMap"; - } catch (err) { - console.error("로그인 실패", err); - } - } - doLogin(); - } else router.push("/map/mainMap"); + // async function doLogin() { + // try { + // await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" }); + // window.location.href = "/map/mainMap"; + // } catch (err) { + // console.error("로그인 실패", err); + // } + // } + // doLogin(); + router.push("/auth/login"); + } else + router.push("/map/mainMap"); } else router.push("/map/mainMap"); }, []); } diff --git a/apps/wr/package.json b/apps/wr/package.json index 0c2cbb93e..a150c00e0 100644 --- a/apps/wr/package.json +++ b/apps/wr/package.json @@ -14,10 +14,12 @@ "@common/assets": "workspace:*", "@common/business_components": "workspace:*", "@common/components": "workspace:*", - "@hookform/resolvers": "^4.1.3", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", + "@hookform/resolvers": "^4.1.3", "@minoru/react-dnd-treeview": "3.5.1-alpha.1", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-slot": "^1.1.2", "@tanstack/react-query": "^5.67.1", "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", @@ -28,7 +30,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "moment": "^2.30.1", - "next": "15.1.7", + "next": "15.3.6", "ol": "^7.3.0", "ol-ext": "^4.0.11", "proj4": "^2.9.0", diff --git a/apps/wr/public/images/bg_login.jpg b/apps/wr/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/wr/public/images/bg_login.jpg differ diff --git a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx index dcc6e81cd..9b59774fc 100644 --- a/apps/wr/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/wr/src/entities/auth/lib/auth-wrapper.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect } from "react"; -import { useRouter, usePathname } from "next/navigation"; +import { useRouter } from "next/navigation"; +import { usePathname } from "next/navigation"; import { client } from "../../../shared/api"; import type { IUserInfo } from "../../../shared/store"; import { useUserStore } from "../../../shared/lib"; @@ -42,16 +43,17 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { } catch (refreshError) { console.error("Token refresh failed:", refreshError); setUserInfo(null); - + const hostname = window.location.hostname; // 개발 환경에서는 localhost 도메인 사용 if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") - router.push("/"); // 개발시 홈으로 돌아가기 + router.push("/auth/login"); // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 // router.push는 같은 Next.js 앱 내에서만 작동하므로 다른 앱으로 이동할 때는 window.location.href 사용 - if (hostname.includes("hasuon.or.kr")) window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트 + if (hostname.includes("hasuon.or.kr")) + window.location.href = LOGIN_PAGE_URL; // 세션 만료 시 CM 로그인 페이지로 리다이렉트 } } })(); diff --git a/apps/wr/src/entities/user/api/user-service.ts b/apps/wr/src/entities/user/api/user-service.ts new file mode 100644 index 000000000..23d509b87 --- /dev/null +++ b/apps/wr/src/entities/user/api/user-service.ts @@ -0,0 +1,225 @@ +import { client } from "@/src/shared/api"; +import { ENDPOINT } from "@/src/shared/config"; +import type { + UserService, + UserSearchParams, + UserList, + UserDetailResponse, + UserSttsStatsList, + UserUpdateData, + UserPasswordUpdateData, + UserFindPasswordUpdateData, + UserPasswordMatchData, + UserUpdateInfoData, + UserSaveData, + UserFindPasswordSaveData, + UserVerifyData, + UserEmailData, + UserIdData, + AdminUserList, +} from "../model/user-interface"; + +export class UserApiService implements UserService { + // 사용자 목록 조회 + public async findPageUser(params: UserSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}`, + { params }, + ); + return response.data; + } + + // 사용자 단건 조회 + public async findByUserUnqId(userUnqId: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + ); + return response.data; + } + + // 로그인한 사용자 정보 조회 + public async findByUserUnqIdByLogin(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + return response.data; + } + + // 사용자 회원상태 통계 조회 + public async selectUserSttsStats(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/stts/stats`, + ); + return response.data; + } + + // 사용자 저장 + public async saveUser(data: UserSaveData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}`, + data, + ); + return response.data; + } + + // 사용자 수정 + public async updateUser( + userUnqId: string, + data: UserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 삭제 + public async deleteUser(userUnqId: string): Promise { + const response = await client.delete( + `${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 처리 + public async lockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 해제처리 + public async unlockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`, + ); + return response.data; + } + + // 리프레시 토큰 정보 재발급 + public async refreshToken(): Promise { + await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`); + } + + // 사용자 비밀번호 변경 + public async updatePassword(data: UserPasswordUpdateData): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/update`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 초기화(관리자용) + public async initializePassword( + userUnqId: string, + data: UserPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 변경 + public async changePassword( + data: UserFindPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/change`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 확인 + public async matchPassword(data: UserPasswordMatchData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/match`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 + public async findPasswordUser( + data: UserFindPasswordSaveData, + ): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/find`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 유효성 확인 + public async validPassword(token: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`, + ); + return response.data; + } + + // 사용자 회원정보 변경 + public async updateInfo( + userUnqId: string, + data: UserUpdateInfoData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 승인처리 + public async applyUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`, + ); + return response.data; + } + + // 사용자 회원탈퇴 + public async leaveUser(data: UserVerifyData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/leave`, + data, + ); + return response.data; + } + + // 사용자 이메일 중복 체크 + public async existsEmail(data: UserEmailData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + data, + ); + return response.data; + } + + // 사용자 아이디 중복 체크 + public async existsUserId(data: UserIdData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + data, + ); + return response.data; + } + + // 관리자 목록 조회 + public async findPageAdminUser( + params: UserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/admin/list`, + { params }, + ); + return response.data; + } +} + +export const UserApi = new UserApiService(); diff --git a/apps/wr/src/entities/user/index.ts b/apps/wr/src/entities/user/index.ts new file mode 100644 index 000000000..8927256f9 --- /dev/null +++ b/apps/wr/src/entities/user/index.ts @@ -0,0 +1,17 @@ +export { UserApi } from "./api/user-service"; +export { + USER_STATE_CODE, + userSaveSchema, + userPasswordUpdateSchema, + userUpdateSchema, + type UserStateCode, + type UserSearchParams, + type UserListResponse, + type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, + type UserUpdateData, + type AdminUserList, + type AdminUserListResponse, +} from "./model/user-interface"; +export { PasswordResetDialog } from "./ui/password-reset-dialog"; diff --git a/apps/wr/src/entities/user/model/user-interface.ts b/apps/wr/src/entities/user/model/user-interface.ts new file mode 100644 index 000000000..be4eff5d2 --- /dev/null +++ b/apps/wr/src/entities/user/model/user-interface.ts @@ -0,0 +1,362 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/src/shared/model"; + +export const USER_STATE_CODE = { + 대기: "00", + 정상: "01", + 정지: "07", + 탈퇴: "08", + 삭제: "09", +} as const; + +export type UserStateCode = + (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE]; + +// 사용자 검색 파라미터 +export interface UserSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + userStateCode?: string; + aprvYn?: string; + grpId?: string; + frDateYmd?: string; + toDateYmd?: string; + userName?: string; +} + +// 사용자 목록 응답 인터페이스 +export interface UserListResponse { + userId: string; + userName: string; + email: string; + roleId: string; + roleName: string; + userStateCode: UserStateCode; + userStateCodeName: string; + lastLoginDate: string; + loginFailCount: number; + userUnqId: string; + mphn: string; + telno: string; + aprvYn: string; + regDt: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + aprvYmd: string; +} + +// 사용자 상세 응답 인터페이스 +export interface UserDetailResponse { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: UserStateCode; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; + twoFactorEnabled: boolean; + adminRoleYn: string; + instSeCd: string; + aprvYmd: string; + enoCd: string; + dtlInstCd: string; + laeSeNm: string; + ogdpSeNm: string; + instSeNm: string; + userStateNm: string; + pswdHntNm: string; + ecoNm: string; + dtlInstNm: string; +} + +// 사용자 상태 통계 응답 인터페이스 +export interface UserSttsStatsResponse { + sttsCd: string; + sttsNm: string; + cnt: number; +} + +// 관리자 목록 응답 인터페이스 +export interface AdminUserListResponse { + userUnqId: string; + userId: string; + userName: string; + groupId: string; + groupNm: string; + userStateCode: string; + userStateCodeName: string; + laeSeCd: string; + laeSeNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + instSeCd: string; + instSeNm: string; + dtlInstCd: string; + dtlInstNm: string; + mdfr: string; + mdfrNm: string; + mdfcnDt: string; +} + +// 사용자 수정 요청 스키마 +export const userUpdateSchema = z.object({ + userId: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + roleId: z.string().optional(), + userStateCode: z.string(), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + aprvYn: z.string(), + autzrId: z.string().uuid().optional(), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + aprvYmd: z.string().max(8).optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 변경 스키마 +export const userPasswordUpdateSchema = z + .object({ + password: z.string().optional(), + provider: z.string().optional(), + token: z.string().optional(), + newPassword: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + newPasswordConfirm: z.string(), + }) + .refine((data) => data.newPassword === data.newPasswordConfirm, { + message: "비밀번호가 일치하지 않습니다.", + path: ["newPasswordConfirm"], + }); + +// 사용자 비밀번호 찾기 변경 스키마 +export const userFindPasswordUpdateSchema = z.object({ + tokenValue: z.string(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 비밀번호 확인 스키마 +export const userPasswordMatchSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), +}); + +// 사용자 회원정보 변경 스키마 +export const userUpdateInfoSchema = z.object({ + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), + userName: z.string(), + email: z.string().email().optional(), +}); + +// 사용자 저장 스키마 +export const userSaveSchema = z.object({ + userId: z.string(), + userName: z.string(), + email: z.string().email().optional(), + password: z + .string() + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + enoCd: z.string().optional(), +}); + +// 사용자 비밀번호 찾기 저장 스키마 +export const userFindPasswordSaveSchema = z.object({ + userName: z.string(), + emailAddr: z.string().email(), + mainUrl: z.string(), + changePasswordUrl: z.string(), +}); + +// 사용자 인증 요청 스키마 +export const userVerifySchema = z.object({ + password: z + .string() + .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) + .optional(), + provider: z.string().optional(), + token: z.string().optional(), +}); + +// 사용자 이메일 요청 스키마 +export const userEmailSchema = z.object({ + userId: z.string().optional(), + email: z.string().email(), +}); + +// 사용자 ID 요청 스키마 +export const userIdSchema = z.object({ + userId: z.string(), +}); + +// Zod 스키마 타입 정의 +export type UserUpdateData = z.infer; +export type UserPasswordUpdateData = z.infer; +export type UserFindPasswordUpdateData = z.infer< + typeof userFindPasswordUpdateSchema +>; +export type UserPasswordMatchData = z.infer; +export type UserUpdateInfoData = z.infer; +export type UserSaveData = z.infer; +export type UserFindPasswordSaveData = z.infer< + typeof userFindPasswordSaveSchema +>; +export type UserVerifyData = z.infer; +export type UserEmailData = z.infer; +export type UserIdData = z.infer; + +// 페이지네이션 타입 정의 +export type UserList = PageableList; +export type UserSttsStatsList = UserSttsStatsResponse[]; +export type AdminUserList = PageableList; + +// 사용자 서비스 인터페이스 +export interface UserService { + // 사용자 목록 조회 + findPageUser: (params: UserSearchParams) => Promise; + + // 사용자 단건 조회 + findByUserUnqId: (userUnqId: string) => Promise; + + // 로그인한 사용자 정보 조회 + findByUserUnqIdByLogin: () => Promise; + + // 사용자 회원상태 통계 조회 + selectUserSttsStats: () => Promise; + + // 사용자 저장 + saveUser: (data: UserSaveData) => Promise; + + // 사용자 수정 + updateUser: (userUnqId: string, data: UserUpdateData) => Promise; + + // 사용자 삭제 + deleteUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 처리 + lockUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 해제처리 + unlockUser: (userUnqId: string) => Promise; + + // 리프레시 토큰 정보 재발급 + refreshToken: () => Promise; + + // 사용자 비밀번호 변경 + updatePassword: (data: UserPasswordUpdateData) => Promise; + + // 사용자 비밀번호 초기화(관리자용) + initializePassword: ( + userUnqId: string, + data: UserPasswordUpdateData, + ) => Promise; + + // 사용자 비밀번호 찾기 변경 + changePassword: (data: UserFindPasswordUpdateData) => Promise; + + // 사용자 비밀번호 확인 + matchPassword: (data: UserPasswordMatchData) => Promise; + + // 사용자 비밀번호 찾기 + findPasswordUser: (data: UserFindPasswordSaveData) => Promise; + + // 사용자 비밀번호 찾기 유효성 확인 + validPassword: (token: string) => Promise; + + // 사용자 회원정보 변경 + updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise; + + // 사용자 승인처리 + applyUser: (userUnqId: string) => Promise; + + // 사용자 회원탈퇴 + leaveUser: (data: UserVerifyData) => Promise; + + // 사용자 이메일 중복 체크 + existsEmail: (data: UserEmailData) => Promise; + + // 사용자 아이디 중복 체크 + existsUserId: (data: UserIdData) => Promise; + + // 관리자 목록 조회 + findPageAdminUser: (params: UserSearchParams) => Promise; +} diff --git a/apps/wr/src/entities/user/ui/password-reset-dialog.tsx b/apps/wr/src/entities/user/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..91dc0dcfb --- /dev/null +++ b/apps/wr/src/entities/user/ui/password-reset-dialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, + Button, + Input, + DialogTrigger, +} from "@common/components"; +import { + UserApi, + userPasswordUpdateSchema, + type UserPasswordUpdateData, +} from "@/src/entities/user"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/src/shared/ui/form"; +import { useAlertStore, useMutationWrapper } from "@/src/shared/lib"; + +interface PasswordResetDialogProps { + userUnqId: string; + children: ReactNode; +} + +export function PasswordResetDialog({ + userUnqId, + children, +}: PasswordResetDialogProps) { + const alert = useAlertStore((state) => state.setMessage); + + const [open, setOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + provider: "password", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다.", () => { + setOpen(false); + form.reset(); + }); + }, + }); + + return ( + + {children} + + + 비밀번호 초기화 + +
+ onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + + + + + +
+
+ ); +} diff --git a/apps/wr/src/features/auth/login/index.ts b/apps/wr/src/features/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/wr/src/features/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/wr/src/features/auth/login/ui/OtpModal.tsx b/apps/wr/src/features/auth/login/ui/OtpModal.tsx new file mode 100644 index 000000000..2c216faaf --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/OtpModal.tsx @@ -0,0 +1,153 @@ +import { useState } from "react"; +import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/src/shared/config"; +import { tokenManager } from "@/src/entities/auth/lib/token-manager"; +import { + Button, + Dialog, + DialogContent, +} from "@common/components"; + +interface OtpModalProps { + onClose: () => void; + onSuccess: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: OtpModalProps) { + const [otp, setOtp] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + try { + setIsLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + console.log("tempToken from localStorage:", tempToken); + + if (!tempToken) { + setError("임시 토큰이 없습니다. 로그인을 다시 시도해주세요."); + return; + } + + // Proxy를 통해 OTP 인증 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + otpCode: otp, + tempToken + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || 'OTP 인증 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("OTP verification response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + console.log("Response data keys:", Object.keys(data)); + console.log("Full response data:", JSON.stringify(data, null, 2)); + + // 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음 + const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token']; + + console.log("Extracted accessToken:", !!accessToken); + console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "..."); + + // access-token이 있는 경우 저장 + if (accessToken) { + console.log("Saving access token from OTP verification"); + tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장 + + // 리프레시 토큰도 확인 + const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token; + if (refreshToken) { + console.log("Saving refresh token"); + tokenManager.setToken(REFRESH_TOKEN, refreshToken); + } + + // userId는 원래 로그인 응답에서 가져온 값 사용 + const originalUserId = data.userId; + if (originalUserId) { + console.log("Saving userId from original login response"); + tokenManager.setToken(AUTH_USER_ID, originalUserId); + } + + console.log("All tokens saved successfully"); + } else { + console.log("No access token in OTP response"); + console.log("Available data keys:", Object.keys(data)); + console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`)); + } + + onSuccess(); + } catch (error: unknown) { + console.error("OTP verification error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("OTP 인증 실패"); + } + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + onClose(); + }; + + return ( + + +
+

OTP 인증

+ +
+ setOtp(e.target.value)} + disabled={isLoading} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/wr/src/features/auth/login/ui/SignupModal.tsx b/apps/wr/src/features/auth/login/ui/SignupModal.tsx new file mode 100644 index 000000000..00a244e13 --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/SignupModal.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { UserSaveData } from "@/src/entities/user"; +import { ENDPOINT, BASE_URL } from "@/src/shared/config"; +import { SignupClient } from "@/src/shared/api/signup-client"; +import "./signup-modal.css"; + +interface SignupModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onClose: () => void; + onSuccess: () => void; +} + +function SignupModal({ open, onClose, onSuccess }: SignupModalProps) { + const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement'); + const [agreed, setAgreed] = useState(false); + const [formData, setFormData] = useState({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingId, setIsCheckingId] = useState(false); + const [isCheckingEmail, setIsCheckingEmail] = useState(false); + const [idAvailable, setIdAvailable] = useState(null); + const [emailAvailable, setEmailAvailable] = useState(null); + + // 모달이 닫힐 때 초기화 + useEffect(() => { + if (!open) { + setCurrentStep('agreement'); + setAgreed(false); + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } + }, [open]); + + const handleAgreementChange = (e: React.ChangeEvent) => { + setAgreed(e.target.checked); + }; + + const handleNextStep = () => { + if (agreed) { + setCurrentStep('signup'); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // 에러 초기화 + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: "" })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + // 필수 필드 검증 + if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; + if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; + if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; + if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; + if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요."; + if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; + if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; + + // 비밀번호 확인 + if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; + } + + // 비밀번호 정규식 검증 + const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; + if (formData.password && !passwordRegex.test(formData.password)) { + newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; + } + + // 이메일 형식 검증 + if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "올바른 이메일 형식을 입력해주세요."; + } + + // 중복 체크 검증 + if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다."; + if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다."; + + setErrors(newErrors); + const isValid = Object.keys(newErrors).length === 0; + console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); + return isValid; + }; + + const checkUserIdDuplicate = async () => { + if (!formData.userId) { + setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." })); + return; + } + + setIsCheckingId(true); + try { + // SignupClient를 통해 중복 체크 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + { userId: formData.userId } + ); + + const exists = response.data; + setIdAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." })); + } else { + setErrors(prev => ({ ...prev, userId: "" })); + } + } catch (error) { + console.error("아이디 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingId(false); + } + }; + + const checkEmailDuplicate = async () => { + if (!formData.email) { + setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." })); + return; + } + + setIsCheckingEmail(true); + try { + // SignupClient를 통해 중복 체크 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + { email: formData.email } + ); + + const exists = response.data; + setEmailAvailable(!exists); + if (exists) { + setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." })); + } else { + setErrors(prev => ({ ...prev, email: "" })); + } + } catch (error) { + console.error("이메일 중복 체크 오류:", error); + setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." })); + } finally { + setIsCheckingEmail(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + console.log("회원가입 버튼 클릭됨"); + + if (!validateForm()) { + console.log("폼 검증 실패"); + return; + } + + setIsLoading(true); + try { + const signupData: UserSaveData = { + userId: formData.userId, + userName: formData.userName, + email: formData.email || undefined, + password: formData.password, + pswdHnt: formData.pswdHnt, + pswdAns: formData.pswdAns || undefined, + mphn: formData.mphn || undefined, + telno: formData.telno || undefined, + zip: formData.zip || undefined, + addr: formData.addr || undefined, + daddr: formData.daddr || undefined, + laeSeCd: formData.laeSeCd, + ogdpSeCd: formData.ogdpSeCd, + expryYmd: "", + instSeCd: formData.instSeCd || undefined, + dtlInstCd: formData.dtlInstCd || undefined, + enoCd: formData.enoCd || undefined, + }; + + // SignupClient를 통해 익명사용자 회원가입 API 호출 + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`, + signupData + ); + + console.log("회원가입 성공:", response.data); + + // 성공 시 모달 닫기 및 콜백 호출 + onSuccess(); + onClose(); + + // 폼 초기화 + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + + } catch (error: unknown) { + console.error("회원가입 오류:", error); + if (error instanceof Error) { + setErrors(prev => ({ ...prev, submit: error.message })); + } else { + setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." })); + } + } finally { + setIsLoading(false); + } + }; + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}

+ +
+ + {currentStep === 'agreement' ? ( +
+
+

개인정보 수집 및 이용 동의

+

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

+ +

1. 수집하는 개인정보 항목

+
    +
  • 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분
  • +
  • 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호
  • +
+ +

2. 개인정보의 수집 및 이용목적

+
    +
  • 회원가입 및 관리
  • +
  • 서비스 제공 및 운영
  • +
  • 고객상담 및 문의응답
  • +
+ +

3. 개인정보의 보유 및 이용기간

+

회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)

+ +

4. 동의 거부권 및 거부 시 불이익

+

개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.

+
+ +
+ +
+ +
+ + +
+
+ ) : ( +
+
+

기본 정보

+ +
+
+ +
+ + +
+ {idAvailable === true && 사용 가능한 아이디입니다.} + {errors.userId && {errors.userId}} +
+
+ +
+
+ + + {errors.userName && {errors.userName}} +
+
+ +
+
+ +
+ + +
+ {emailAvailable === true && 사용 가능한 이메일입니다.} + {errors.email && {errors.email}} +
+
+ +
+
+ + + {errors.password && {errors.password}} +
+
+ +
+
+ + + {errors.confirmPassword && {errors.confirmPassword}} +
+
+ +
+
+ + + {errors.pswdHnt && {errors.pswdHnt}} +
+
+ +
+
+ + + {errors.pswdAns && {errors.pswdAns}} +
+
+
+ +
+

연락처 정보

+ +
+
+ + + {errors.mphn && {errors.mphn}} +
+
+ +
+
+ + + {errors.telno && {errors.telno}} +
+
+
+ +
+

주소 정보

+ +
+
+ + + {errors.zip && {errors.zip}} +
+
+ +
+
+ + + {errors.addr && {errors.addr}} +
+
+ +
+
+ + + {errors.daddr && {errors.daddr}} +
+
+
+ +
+

기관 정보

+ +
+
+ + + {errors.laeSeCd && {errors.laeSeCd}} +
+
+ +
+
+ + + {errors.ogdpSeCd && {errors.ogdpSeCd}} +
+
+ +
+
+ + + {errors.instSeCd && {errors.instSeCd}} +
+
+ +
+
+ + + {errors.dtlInstCd && {errors.dtlInstCd}} +
+
+
+ + {errors.submit && ( +
+
{errors.submit}
+
+ )} + +
+ + + +
+ + )} +
+
+ ); +} + +export default SignupModal; diff --git a/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx b/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx new file mode 100644 index 000000000..d66747b0c --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/TwoFAModal.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { + Button, + Dialog, + DialogContent, +} from "@common/components"; + +interface TwoFAModalProps { + onClose: () => void; + onSuccess: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function TwoFAModal({ onClose, onSuccess, open, onOpenChange }: TwoFAModalProps) { + const [qrImage, setQrImage] = useState(""); + const [secret, setSecret] = useState(""); + const [otp, setOtp] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSetupLoading, setIsSetupLoading] = useState(true); + + useEffect(() => { + const fetchQR = async () => { + try { + setIsSetupLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + if (!tempToken) { + setError("임시 토큰이 없습니다"); + return; + } + + // Proxy를 통해 2FA setup 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tempToken }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || 'QR코드 발급 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA setup response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + + // Proxy 응답에서 QR 데이터 추출 + setQrImage(data.qrImage); + setSecret(data.secret); + } catch (error: unknown) { + console.error("2FA setup error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("QR코드 발급 실패"); + } + } finally { + setIsSetupLoading(false); + } + }; + + if (open) { + fetchQR(); + } + }, [open]); + + const handleEnable2FA = async () => { + try { + setIsLoading(true); + setError(""); + + const tempToken = localStorage.getItem("tempToken"); + if (!tempToken) { + setError("임시 토큰이 없습니다"); + return; + } + + // Proxy를 통해 2FA enable 처리 + const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/enable`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': tempToken, + }, + body: JSON.stringify({ + otpCode: otp, + tempToken: tempToken, + secret: secret, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + let errorMsg = errorData.message || '2FA 등록 실패'; + if (Array.isArray(errorData.errors) && errorData.errors.length > 0) { + errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', '); + } + throw new Error(errorMsg); + } + + const data = await response.json(); + console.log("2FA enable response:", data); + console.log("Response headers:", Object.fromEntries(response.headers.entries())); + console.log("Response data keys:", Object.keys(data)); + + // 2FA 설정 완료 후에는 토큰을 저장하지 않음 (실제 로그인은 OTP에서 처리) + console.log("2FA setup completed successfully"); + + onSuccess(); + } catch (error: unknown) { + console.error("2FA enable error:", error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("2FA 등록 실패"); + } + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + onOpenChange(false); + onClose(); + }; + + return ( + + +
+

2FA 등록

+ + {isSetupLoading ? ( +
+
QR코드 로딩 중...
+
+ ) : ( + <> + {qrImage && ( +
+ +
+ )} + +
+ setOtp(e.target.value)} + disabled={isLoading} + /> + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ + )} +
+
+
+ ); +} diff --git a/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx b/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx new file mode 100644 index 000000000..8d3220e8e --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/UserSignupModal.tsx @@ -0,0 +1,901 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { SignupClient } from "@/src/shared/api/signup-client"; +import { BASE_URL, ENDPOINT } from "@/src/shared/config"; +import "./user-signup-modal.css"; + +interface UserSignupModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +interface UserSignupData { + userId: string; + userName: string; + email?: string; + password: string; + pswdHnt: string; + pswdAns?: string; + mphn?: string; + telno?: string; + zip?: string; + addr?: string; + daddr?: string; + laeSeCd: string; + ogdpSeCd: string; + instSeCd?: string; + dtlInstCd?: string; + enoCd?: string; +} + +// 익명 사용자용 CodeSelect 컴포넌트 +interface AnonymousCodeSelectProps { + upCd: string; + value: string; + onValueChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +function AnonymousCodeSelect({ + upCd, + value, + onValueChange, + disabled, + placeholder = "선택", +}: AnonymousCodeSelectProps) { + const [codes, setCodes] = useState< + Array<{ lwpoCd: string; lwpoCdNm: string }> + >([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchCodes = async () => { + if (!upCd) return; + + setIsLoading(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + + const response = await signupClient.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`, + ); + + if (response.data && Array.isArray(response.data)) { + setCodes(response.data); + } else { + setCodes([]); + } + } catch (error) { + console.error(`공통 코드 로드 실패 (${upCd}):`, error); + setCodes([]); + } finally { + setIsLoading(false); + } + }; + + fetchCodes(); + }, [upCd]); + + return ( + + ); +} + +function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) { + const [currentStep, setCurrentStep] = useState<"agreement" | "signup">( + "agreement", + ); + const [agreed, setAgreed] = useState(false); + const [formData, setFormData] = useState({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + + const [errors, setErrors] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [isCheckingId, setIsCheckingId] = useState(false); + const [isCheckingEmail, setIsCheckingEmail] = useState(false); + const [idAvailable, setIdAvailable] = useState(null); + const [emailAvailable, setEmailAvailable] = useState(null); + + // 모달이 닫힐 때 초기화 + useEffect(() => { + if (!open) { + setCurrentStep("agreement"); + setAgreed(false); + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } + }, [open]); + + const handleAgreementChange = (e: React.ChangeEvent) => { + setAgreed(e.target.checked); + }; + + const handleNextStep = () => { + if (agreed) { + setCurrentStep("signup"); + } + }; + + const handleInputChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + // 에러 초기화 + if (errors[name]) { + setErrors((prev) => ({ ...prev, [name]: "" })); + } + }; + + const validateForm = () => { + const newErrors: Record = {}; + + console.log("폼 검증 시작:", formData); + + // 필수 필드 검증 + if (!formData.userId) newErrors.userId = "아이디를 입력해주세요."; + if (!formData.userName) newErrors.userName = "이름을 입력해주세요."; + if (!formData.password) newErrors.password = "비밀번호를 입력해주세요."; + if (!formData.confirmPassword) + newErrors.confirmPassword = "비밀번호 확인을 입력해주세요."; + if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 선택해주세요."; + if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요."; + if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요."; + + // 비밀번호 확인 + if ( + formData.password && + formData.confirmPassword && + formData.password !== formData.confirmPassword + ) { + newErrors.confirmPassword = "비밀번호가 일치하지 않습니다."; + } + + // 비밀번호 정규식 검증 + const passwordRegex = + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/; + if (formData.password && !passwordRegex.test(formData.password)) { + newErrors.password = + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다."; + } + + // 이메일 형식 검증 + if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "올바른 이메일 형식을 입력해주세요."; + } + + // 중복 체크 검증 + if (idAvailable === false) + newErrors.userId = "이미 사용 중인 아이디입니다."; + if (emailAvailable === false) + newErrors.email = "이미 사용 중인 이메일입니다."; + + setErrors(newErrors); + const isValid = Object.keys(newErrors).length === 0; + console.log("폼 검증 결과:", { isValid, errors: newErrors, formData }); + return isValid; + }; + + const checkUserIdDuplicate = async () => { + if (!formData.userId) { + setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." })); + return; + } + + setIsCheckingId(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + { userId: formData.userId }, + ); + + const exists = response.data; + setIdAvailable(!exists); + if (exists) { + setErrors((prev) => ({ + ...prev, + userId: "이미 사용 중인 아이디입니다.", + })); + } else { + setErrors((prev) => ({ ...prev, userId: "" })); + } + } catch (error) { + console.error("아이디 중복 체크 오류:", error); + setErrors((prev) => ({ + ...prev, + userId: "아이디 중복 체크 중 오류가 발생했습니다.", + })); + } finally { + setIsCheckingId(false); + } + }; + + const checkEmailDuplicate = async () => { + if (!formData.email) { + setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." })); + return; + } + + setIsCheckingEmail(true); + try { + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + { email: formData.email }, + ); + + const exists = response.data; + setEmailAvailable(!exists); + if (exists) { + setErrors((prev) => ({ + ...prev, + email: "이미 사용 중인 이메일입니다.", + })); + } else { + setErrors((prev) => ({ ...prev, email: "" })); + } + } catch (error) { + console.error("이메일 중복 체크 오류:", error); + setErrors((prev) => ({ + ...prev, + email: "이메일 중복 체크 중 오류가 발생했습니다.", + })); + } finally { + setIsCheckingEmail(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + console.log("회원가입 버튼 클릭됨 - handleSubmit"); + console.log("현재 폼 데이터:", formData); + + if (!validateForm()) { + console.log("폼 검증 실패"); + return; + } + + console.log("폼 검증 성공, 회원가입 API 호출 시작"); + setIsLoading(true); + try { + const signupData: UserSignupData = { + userId: formData.userId, + userName: formData.userName, + email: formData.email || undefined, + password: formData.password, + pswdHnt: formData.pswdHnt, + pswdAns: formData.pswdAns || undefined, + mphn: formData.mphn || undefined, + telno: formData.telno || undefined, + zip: formData.zip || undefined, + addr: formData.addr || undefined, + daddr: formData.daddr || undefined, + laeSeCd: formData.laeSeCd, + ogdpSeCd: formData.ogdpSeCd, + instSeCd: formData.instSeCd || undefined, + dtlInstCd: formData.dtlInstCd || undefined, + enoCd: formData.enoCd || undefined, + }; + + // SignupClient를 통해 익명사용자 회원가입 API 호출 + console.log("SignupClient 생성 및 API 호출:", { + baseURL: BASE_URL, + endpoint: ENDPOINT.USER_SERVICE.USER_JOIN, + data: signupData, + }); + + const signupClient = SignupClient.getInstance({ + baseURL: BASE_URL, + }); + const response = await signupClient.post( + ENDPOINT.USER_SERVICE.USER_JOIN, + signupData, + ); + + console.log("회원가입 성공:", response.data); + + // 성공 시 모달 닫기 및 콜백 호출 + onSuccess(); + onClose(); + + // 폼 초기화 + setFormData({ + userId: "", + userName: "", + email: "", + password: "", + confirmPassword: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + instSeCd: "", + dtlInstCd: "", + enoCd: "", + }); + setErrors({}); + setIdAvailable(null); + setEmailAvailable(null); + } catch (error: unknown) { + console.error("회원가입 오류:", error); + if (error instanceof Error) { + setErrors((prev) => ({ ...prev, submit: error.message })); + } else { + setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." })); + } + } finally { + setIsLoading(false); + } + }; + + if (!open) return null; + + return ( +
+
+
+

+ {currentStep === "agreement" ? "개인정보제공동의" : "회원가입"} +

+ +
+ + {currentStep === "agreement" ? ( +
+
+

개인정보 수집 및 이용 동의

+

회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.

+ +

1. 수집하는 개인정보 항목

+
    +
  • + 필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, + 기관구분 +
  • +
  • + 선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, + 상세기관코드, 사업자등록번호 +
  • +
+ +

2. 개인정보의 수집 및 이용목적

+
    +
  • 회원가입 및 관리
  • +
  • 서비스 제공 및 운영
  • +
  • 고객상담 및 문의응답
  • +
+ +

3. 개인정보의 보유 및 이용기간

+

+ 회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 + 기간까지) +

+ +

4. 동의 거부권 및 거부 시 불이익

+

+ 개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 + 시 회원가입이 제한됩니다. +

+
+ +
+ +
+ +
+ + +
+
+ ) : ( +
+
+

기본 정보

+ +
+
+ +
+ + +
+ {idAvailable === true && ( + + 사용 가능한 아이디입니다. + + )} + {errors.userId && ( + + {errors.userId} + + )} +
+
+ +
+
+ + + {errors.userName && ( + + {errors.userName} + + )} +
+
+ +
+
+ +
+ + +
+ {emailAvailable === true && ( + + 사용 가능한 이메일입니다. + + )} + {errors.email && ( + + {errors.email} + + )} +
+
+ +
+
+ + + {errors.password && ( + + {errors.password} + + )} +
+
+ +
+
+ + + {errors.confirmPassword && ( + + {errors.confirmPassword} + + )} +
+
+ +
+
+ + + setFormData((prev) => ({ ...prev, pswdHnt: value })) + } + disabled={isLoading} + placeholder="비밀번호 힌트를 선택하세요" + /> + {errors.pswdHnt && ( + + {errors.pswdHnt} + + )} +
+
+ +
+
+ + + {errors.pswdAns && ( + + {errors.pswdAns} + + )} +
+
+
+ +
+

연락처 정보

+ +
+
+ + + {errors.mphn && ( + + {errors.mphn} + + )} +
+
+ +
+
+ + + {errors.telno && ( + + {errors.telno} + + )} +
+
+
+ +
+

주소 정보

+ +
+
+ + + {errors.zip && ( + + {errors.zip} + + )} +
+
+ +
+
+ + + {errors.addr && ( + + {errors.addr} + + )} +
+
+ +
+
+ + + {errors.daddr && ( + + {errors.daddr} + + )} +
+
+
+ +
+

기관 정보

+ +
+
+ + + setFormData((prev) => ({ ...prev, laeSeCd: value })) + } + disabled={isLoading} + placeholder="법인구분을 선택하세요" + /> + {errors.laeSeCd && ( + + {errors.laeSeCd} + + )} +
+
+ +
+
+ + + setFormData((prev) => ({ ...prev, ogdpSeCd: value })) + } + disabled={isLoading} + placeholder="기관구분을 선택하세요" + /> + {errors.ogdpSeCd && ( + + {errors.ogdpSeCd} + + )} +
+
+ +
+
+ + + {errors.instSeCd && ( + + {errors.instSeCd} + + )} +
+
+ +
+
+ + + {errors.dtlInstCd && ( + + {errors.dtlInstCd} + + )} +
+
+
+ + {errors.submit && ( +
+
+ {errors.submit} +
+
+ )} + +
+ + + +
+ + )} +
+
+ ); +} + +export default UserSignupModal; diff --git a/apps/wr/src/features/auth/login/ui/login-page.tsx b/apps/wr/src/features/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..955ddd24d --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/login-page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import Image from "next/image"; +import { images } from "@common/assets"; +import { LoginApi } from "@/src/entities/auth"; +import "./login.css"; + +function LoginPage() { + const hostname = window.location.hostname; + if (hostname.includes("hasuon.or.kr")) window.location.href = process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL||'/auth/login'; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const formData = new FormData(e.target as HTMLFormElement); + const userId = formData.get("id") as string; + const password = formData.get("password") as string; + + await LoginApi.login({ userId, password }); + window.location.href = "/map/mainMap"; + }; + + return ( +
+
+
+

+ 하수도정보시스템 +
+ CMS 통합관리시스템 +

+

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

+
+
+ +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+ {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} +
+ +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; + diff --git a/apps/wr/src/features/auth/login/ui/login.css b/apps/wr/src/features/auth/login/ui/login.css new file mode 100644 index 000000000..af73c9c68 --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/login.css @@ -0,0 +1,310 @@ +/** +* OLYM Cascading Style Sheets +* File Name : login.css +* Description : 로그인 스타일시트 +* Author : Web business Team / Choi Hyun Mi +* Email : desingtj@olym.co.kr +* Date : 2023.07.20 +* Update : 2023.07.26 +* Copyright(c) 2023 OLYM Communications. All Rights Reserved. +*/ +html, +body { + background: #fff; +} +.fx { + display: flex; + -ms-display: flex; + -webkit-display: flex; + box-sizing: border-box; +} +.login { + width: 100%; + height: 100vh; +} +.login .left_box { + width: 100%; + background: url(/images/bg_login.jpg) no-repeat center/cover; + flex-direction: column; + justify-content: space-between; + padding: 7% 5% 5% 5%; +} +.login .left_box .text h1 { + font-size: 53px; + color: #fff; +} +.login .left_box .text h1 span { + font-weight: 800; +} +.login .left_box .text p { + margin-top: 40px; + font-size: 18px; + color: #fff; +} +.login .login_box { + width: 100%; + flex-direction: column; + justify-content: center; + padding: 4% 12% 4% 6%; +} +.login .login_box > ul { + flex-direction: column; + gap: 40px; +} +.login .login_box > ul > li h2 { + font-size: 22px; + font-weight: 600; + color: #2640a5; + margin-bottom: 14px; +} +.login .login_box > ul > li .input_st { + position: relative; +} +.login .login_box > ul > li .input_st .diveicon { + position: absolute; + top: 50%; + left: 28px; + transform: translateY(-50%); + font-family: "Xeicon"; + font-size: 25px; + color: #003288; +} +.login .login_box > ul > li input { + width: 100%; + padding: 20px 20px 20px 70px; + background: #f7f7f7; + box-sizing: border-box; + border: none; + border-radius: 5px; + font-size: 18px; + font-weight: 700; + color: #000; + height: auto; +} +.login .login_box > ul > li input:focus { + outline: 1px solid #3c5fe5; +} +.login .login_box > ul > li input::placeholder { + color: #afabab; + font-weight: 500; +} +.login .login_box .bottom_box { + justify-content: space-between; + gap: 20px; + padding: 50px 0 45px 0; +} +.login .login_box .bottom_box .checks { + display: flex; + align-items: center; + gap: 8px; +} +.login .login_box .bottom_box .checks input[type="checkbox"] { + width: 18px; + height: 18px; +} +.login .login_box .bottom_box .checks label { + font-size: 14px; +} +.login .login_box .bottom_box .pw_message { + align-items: center; + gap: 5px; + color: #636363; + font-weight: 500; + border-bottom: 1px solid #ccc; + padding-bottom: 5px; +} +.login .login_box .bottom_box .pw_message i { + color: #327af5; + font-size: 20px; +} +.login .login_box .login_btn { + position: relative; + border-radius: 5px; + padding: 24px 15px; + background: linear-gradient( + to left, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + overflow: hidden; + justify-content: center; + align-items: center; + gap: 10px; + color: #fff; + cursor: pointer; +} +.login .login_box .login_btn span { + font-family: "Montserrat"; + font-size: 19px; + font-weight: 700; + position: relative; +} +.login .login_box .login_btn i { + font-size: 22px; + transform: translateY(-2px); +} +.login .login_box .login_btn:before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + background: linear-gradient( + to right, + #1d3969 0%, + #264078 10%, + #172558 40%, + #162254 50%, + #111c43 60%, + #111c42 100% + ); + transition: opacity 0.5s; +} +.login .login_box .login_btn:hover::before { + opacity: 1; +} +.login .footer .copy { + margin-top: 36px; + justify-content: space-between; + align-items: center; + gap: 3%; +} +.login .footer .copy p { + font-size: 13px; + color: #d4d4d4; + width: 44%; + text-align: center; +} +.login .footer .copy::before, +.login .login_box .copy::after { + content: ""; + width: 20%; + height: 1px; + background: #d4d4d4; +} +.login .footer .fot_btn { + gap: 20px; + margin: 30px 0 10px 0; +} +.login .footer .fot_btn a { + color: #333; + font-weight: 700; + font-size: 13px; +} +.login .footer .fot_btn a:hover { + color: #003288; +} +.login .footer .addr { + color: #666; + font-size: 13px; + gap: 3px 20px; + flex-wrap: wrap; +} +/* 비밀번호 변경 팝업창 */ +.login .modal .modal_container { + width: 750px; +} +.login .pw_desc { + padding: 10px 10px 30px 10px; + color: #666; +} +.login .pw_desc p { + font-size: 1.25rem; + color: #000; + font-weight: 700; + margin-bottom: 10px; +} +.login .pw_desc span { + color: #f60; + font-weight: 700; +} +.login .modal .form_write { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 20px 50px; +} +@media (max-width: 1024px) { + .login { + flex-direction: column; + height: auto; + } + .login .left_box { + padding: 30px; + flex-direction: column-reverse; + gap: 20px; + } + .login .left_box .text h1 { + font-size: 30px; + line-height: 1.2em; + } + .login .left_box .text p { + margin-top: 20px; + font-size: 14px; + } + .login .left_box .img img { + height: 40px; + } + .login .login_box { + padding: 30px; + } + .login .login_box > ul { + gap: 20px; + } + .login .login_box > ul > li h2 { + margin-bottom: 7px; + } + .login .login_box > ul > li .input_st::after { + left: 15px; + font-size: 20px; + } + .login .login_box > ul > li input { + padding: 15px 10px 15px 50px; + font-size: 16px; + } + .login .login_box .bottom_box { + flex-direction: column; + align-items: flex-start; + padding: 30px 0; + } + .login .login_box .bottom_box .id_save label { + font-size: 14px; + } + .login .login_box .login_btn { + padding: 15px; + } + .login .login_box .login_btn span { + font-size: 16px; + } + .login .login_box .login_btn i { + font-size: 18px; + transform: translateY(-1px); + } + .login .login_box .copy p { + width: 100%; + } + .login .login_box .copy::before, + .login .login_box .copy::after { + display: none; + } + .login .footer .addr { + font-size: 11px; + gap: 3px 10px; + } + .login .modal .modal_container { + width: 90%; + } + .login .pw_desc { + padding: 0 0 20px 0; + } + .login .modal .form_write { + padding: 10px; + } +} +/* // login */ diff --git a/apps/wr/src/features/auth/login/ui/signup-modal.css b/apps/wr/src/features/auth/login/ui/signup-modal.css new file mode 100644 index 000000000..0fb1585c9 --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/signup-modal.css @@ -0,0 +1,277 @@ +/* 모달 오버레이 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +/* 모달 컨텐츠 */ +.modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +/* 모달 헤더 */ +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; + background-color: #f9fafb; + border-radius: 8px 8px 0 0; +} + +.modal-header h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: #1f2937; +} + +.close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6b7280; + padding: 4px; + border-radius: 4px; + transition: color 0.2s; +} + +.close-button:hover { + color: #374151; +} + +/* 폼 스타일 */ +.signup-form { + padding: 24px; +} + +.form-section { + margin-bottom: 32px; +} + +.form-section h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; + padding-bottom: 8px; + border-bottom: 2px solid #e5e7eb; +} + +.form-row { + margin-bottom: 16px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + margin-bottom: 6px; + font-weight: 500; + color: #374151; + font-size: 1.1rem; +} + +.form-group input, +.form-group select { + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-group input:disabled, +.form-group select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* 입력 필드와 버튼이 함께 있는 경우 */ +.input-with-button { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.input-with-button input { + flex: 1; +} + +.check-button { + padding: 10px 16px; + background-color: #6b7280; + color: white; + border: none; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; + min-width: 80px; +} + +.check-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.check-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 메시지 스타일 */ +.error-message { + color: #dc2626; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.success-message { + color: #059669; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.submit-error { + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; + text-align: center; +} + +/* 폼 액션 버튼 */ +.form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + margin-top: 24px; +} + +.cancel-button, +.submit-button { + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + border: none; + min-width: 100px; +} + +.cancel-button { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.cancel-button:hover:not(:disabled) { + background-color: #e5e7eb; +} + +.submit-button { + background-color: #3b82f6; + color: white; +} + +.submit-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.cancel-button:disabled, +.submit-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .modal-content { + margin: 10px; + max-height: 95vh; + } + + .modal-header { + padding: 16px 20px; + } + + .signup-form { + padding: 20px; + } + + .form-actions { + flex-direction: column; + } + + .cancel-button, + .submit-button { + width: 100%; + } + + .input-with-button { + flex-direction: column; + align-items: stretch; + } + + .check-button { + width: 100%; + margin-top: 8px; + } +} + +/* 스크롤바 스타일 */ +.modal-content::-webkit-scrollbar { + width: 6px; +} + +.modal-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.modal-content::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.modal-content::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} diff --git a/apps/wr/src/features/auth/login/ui/user-signup-modal.css b/apps/wr/src/features/auth/login/ui/user-signup-modal.css new file mode 100644 index 000000000..241929a2a --- /dev/null +++ b/apps/wr/src/features/auth/login/ui/user-signup-modal.css @@ -0,0 +1,436 @@ +/* 신규 사용자 가입 모달 오버레이 */ +.user-signup-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +/* 신규 사용자 가입 모달 컨텐츠 */ +.user-signup-modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 800px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +/* 신규 사용자 가입 모달 헤더 */ +.user-signup-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; + background-color: #f9fafb; + border-radius: 8px 8px 0 0; +} + +.user-signup-modal-header h2 { + margin: 0; + font-size: 1.8rem; + font-weight: 600; + color: #1f2937; +} + +.user-signup-close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6b7280; + padding: 4px; + border-radius: 4px; + transition: color 0.2s; +} + +.user-signup-close-button:hover { + color: #374151; +} + +/* 신규 사용자 가입 폼 스타일 */ +.user-signup-form { + padding: 24px; +} + +.user-signup-form-section { + margin-bottom: 32px; +} + +.user-signup-form-section h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; + padding-bottom: 8px; + border-bottom: 2px solid #e5e7eb; +} + +.user-signup-form-row { + margin-bottom: 16px; +} + +.user-signup-form-group { + display: flex; + flex-direction: column; +} + +.user-signup-form-group label { + margin-bottom: 6px; + font-weight: 500; + color: #374151; + font-size: 1.1rem; +} + +.user-signup-form-group input, +.user-signup-form-group select { + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +/* CodeSelect 컴포넌트 스타일링 */ +.user-signup-form-group .CodeSelect { + width: 100%; +} + +.user-signup-form-group .CodeSelect select { + width: 100%; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; +} + +.user-signup-form-group .CodeSelect select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-form-group .CodeSelect select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* Select 요소 스타일링 */ +.user-signup-select { + width: 100%; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 1.1rem; + transition: border-color 0.2s, box-shadow 0.2s; + background-color: white; + cursor: pointer; +} + +.user-signup-select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +.user-signup-select option { + padding: 8px; + font-size: 1rem; +} + +.user-signup-form-group input:focus, +.user-signup-form-group select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.user-signup-form-group input:disabled, +.user-signup-form-group select:disabled { + background-color: #f3f4f6; + color: #6b7280; + cursor: not-allowed; +} + +/* 입력 필드와 버튼이 함께 있는 경우 */ +.user-signup-input-with-button { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.user-signup-input-with-button input { + flex: 1; +} + +.user-signup-check-button { + padding: 10px 16px; + background-color: #6b7280; + color: white; + border: none; + border-radius: 6px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; + min-width: 80px; +} + +.user-signup-check-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.user-signup-check-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 메시지 스타일 */ +.user-signup-error-message { + color: #dc2626; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.user-signup-success-message { + color: #059669; + font-size: 0.8rem; + margin-top: 4px; + display: block; +} + +.user-signup-submit-error { + background-color: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; + text-align: center; +} + +/* 폼 액션 버튼 */ +.user-signup-form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + margin-top: 24px; +} + +.user-signup-cancel-button, +.user-signup-submit-button, +.user-signup-back-button { + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + border: none; + min-width: 100px; +} + +.user-signup-cancel-button { + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.user-signup-cancel-button:hover:not(:disabled) { + background-color: #e5e7eb; +} + +.user-signup-submit-button { + background-color: #3b82f6; + color: white; +} + +.user-signup-submit-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.user-signup-back-button { + background-color: #6b7280; + color: white; +} + +.user-signup-back-button:hover:not(:disabled) { + background-color: #4b5563; +} + +.user-signup-cancel-button:disabled, +.user-signup-submit-button:disabled, +.user-signup-back-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 개인정보제공동의 스타일 */ +.user-signup-agreement-content { + padding: 24px; +} + +.user-signup-agreement-text { + margin-bottom: 24px; +} + +.user-signup-agreement-text h3 { + margin: 0 0 16px 0; + font-size: 1.4rem; + font-weight: 600; + color: #374151; +} + +.user-signup-agreement-text h4 { + margin: 20px 0 8px 0; + font-size: 1.1rem; + font-weight: 600; + color: #374151; +} + +.user-signup-agreement-text p { + margin: 8px 0; + color: #6b7280; + line-height: 1.5; +} + +.user-signup-agreement-text ul { + margin: 8px 0; + padding-left: 20px; + color: #6b7280; +} + +.user-signup-agreement-text li { + margin: 4px 0; + line-height: 1.5; +} + +.user-signup-agreement-checkbox { + margin-bottom: 24px; + padding: 16px; + background-color: #f9fafb; + border-radius: 6px; + border: 1px solid #e5e7eb; +} + +.user-signup-agreement-checkbox label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 500; + color: #374151; +} + +.user-signup-agreement-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; +} + +.user-signup-agreement-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.user-signup-next-button { + padding: 12px 24px; + background-color: #3b82f6; + color: white; + border: none; + border-radius: 6px; + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s; + min-width: 100px; +} + +.user-signup-next-button:hover:not(:disabled) { + background-color: #2563eb; +} + +.user-signup-next-button:disabled { + background-color: #d1d5db; + cursor: not-allowed; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .user-signup-modal-content { + margin: 10px; + max-height: 95vh; + } + + .user-signup-modal-header { + padding: 16px 20px; + } + + .user-signup-form { + padding: 20px; + } + + .user-signup-form-actions { + flex-direction: column; + } + + .user-signup-cancel-button, + .user-signup-submit-button, + .user-signup-back-button { + width: 100%; + } + + .user-signup-input-with-button { + flex-direction: column; + align-items: stretch; + } + + .user-signup-check-button { + width: 100%; + margin-top: 8px; + } +} + +/* 스크롤바 스타일 */ +.user-signup-modal-content::-webkit-scrollbar { + width: 6px; +} + +.user-signup-modal-content::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.user-signup-modal-content::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.user-signup-modal-content::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} diff --git a/apps/wr/src/shared/api/signup-client.ts b/apps/wr/src/shared/api/signup-client.ts new file mode 100644 index 000000000..2cd459cd7 --- /dev/null +++ b/apps/wr/src/shared/api/signup-client.ts @@ -0,0 +1,71 @@ +import axios from "axios"; +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; + +interface SignupClientConfig { + baseURL: string; +} + +class SignupClient { + private static instance: SignupClient; + private axiosInstance: AxiosInstance; + + private constructor(config: SignupClientConfig) { + this.axiosInstance = axios.create({ + baseURL: config.baseURL, + headers: { + "Content-Type": "application/json", + }, + withCredentials: true, + }); + + // 인증 토큰 없이도 요청할 수 있도록 인터셉터 설정 + this.axiosInstance.interceptors.request.use((config) => { + // 인증 토큰을 자동으로 추가하지 않음 + return config; + }); + } + + public static getInstance(config: SignupClientConfig): SignupClient { + if (!SignupClient.instance) { + SignupClient.instance = new SignupClient(config); + } + return SignupClient.instance; + } + + public async get>( + url: string, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.get(url, config); + return response; + } + + public async post, D = unknown>( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.post(url, data, config); + return response; + } + + public async put, D = unknown>( + url: string, + data?: D, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.put(url, data, config); + return response; + } + + public async delete>( + url: string, + config?: AxiosRequestConfig + ): Promise { + const response = await this.axiosInstance.delete(url, config); + return response; + } +} + +export type { SignupClientConfig }; +export { SignupClient }; diff --git a/apps/wr/src/shared/config/api.ts b/apps/wr/src/shared/config/api.ts index b0d1a5be2..eebb15c19 100644 --- a/apps/wr/src/shared/config/api.ts +++ b/apps/wr/src/shared/config/api.ts @@ -14,13 +14,21 @@ export const ENDPOINT = { }, USER_SERVICE: { LOGIN: "/user-service/login", + LOGIN_2FA: "/user-service/api/v1/auth/login", USERS: "/user-service/api/v1/users", SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", AUTHORIZATIONS: "/user-service/api/v1/authorizations", ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations", ROLES: "/user-service/api/v1/roles", + LOGINLOG: "/user-service/api/v1/login-logs", + PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs", + OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify", + TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup", + TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable", + USER_JOIN: "/user-service/api/v1/anonymouse/users/join", }, CMS_SERVICE: { + DASHBOARD: "/cms-service/api/v1/dashboard", SITES: "/cms-service/api/v1/sites", IPS: "/cms-service/api/v1/site-ips", DOMAINS: "/cms-service/api/v1/site-dmns", @@ -29,6 +37,8 @@ export const ENDPOINT = { MENUS: "/cms-service/api/v1/menus", MENU_ROLES: "/cms-service/api/v1/menu-roles", MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts", + MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg", + MENU_HISTORY: "/cms-service/api/v1/menu-hstry", CONTENTS: "/cms-service/api/v1/contents", CONTENTS_DETAILS: "/cms-service/api/v1/contents-details", ORGANIZATIONS: "/cms-service/api/v1/ognzs", @@ -44,13 +54,32 @@ export const ENDPOINT = { SURVEY: "/cms-service/api/v1/srvy", SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem", SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl", + ZIPS: "/cms-service/api/v1/zips", + GROUPS: "/cms-service/api/v1/groups", + GROUP_USERS: "/cms-service/api/v1/group-users", + MANAGER_IP: "/cms-service/api/v1/mgrip", + APILOG: "/cms-service/api/v1/api-logs", + SITE_AUTHORITY: "/cms-service/api/v1/site-authrt", + CONNECTION_LOG: "/cms-service/api/v1/cntnlog", }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt", POSTS: "/board-service/api/v1/posts", POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", + POST_EVALUATION: "/board-service/api/v1/pstEvl", }, + SEWERAGE_SERVICE: { + PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts", + OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts", + }, + IC_SERVICE: { + GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics", + }, + DC_SERVICE: { + DATA_STATUS: "/dc-service/api/v1/dc/dataStatus", + }, + }; export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/wr/src/shared/ui/form.tsx b/apps/wr/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/wr/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +