Compare commits

...
Author SHA1 Message Date
mind-one007 1bace82df9 env.local loalhosst로 변경 2026-04-23 13:58:32 +09:00
mind-one005 1f1c5bd603 wr: 수정 2026-04-20 17:39:13 +09:00
mind-one005 9cf9081efc wr: 통계 2026-04-20 17:20:12 +09:00
mind-one005 3d2dcb4ead wr: 빗물이용시설 통계1 2026-04-17 17:58:09 +09:00
mind-one005 f88b4dfc7e sn: 로그인 페이지 추가 및 남은시간 2026-04-17 15:01:56 +09:00
mind-one005 75eaaa81ac sn: use client 추가 2026-04-17 13:15:12 +09:00
mind-one005 c164b02081 sn: 무한 루프 수정 2026-04-17 11:45:16 +09:00
mind-one005 24f377e048 wr: 필요없는 코드 삭제 2026-04-16 18:17:55 +09:00
mind-one005 5f954c0a9d fo: header에 site-id추가 2026-04-15 15:59:22 +09:00
mind-one005 0e81ed1c07 sn: 파일 삭제 2026-04-15 10:42:34 +09:00
mind-one005 4dacfdaadc sn: header에 site-id 추가 2026-04-15 10:18:33 +09:00
mind-one005 812293b4fa wr: header에 site_id추가 2026-04-14 17:39:12 +09:00
mind-one005 0e57bbc4e8 wr: 하수재이용실적현황 지자체 2026-04-14 15:45:20 +09:00
mind-one005 0411b2a894 wr: 재이용시설현황 지차제 2026-04-14 14:57:26 +09:00
mind-one005 7359f19e7d wr: 하수처리시설현황 지자체 2026-04-13 18:18:19 +09:00
mind-one005 2de065ceb0 wr: jwt 남은시간 2026-04-10 18:01:12 +09:00
mind-one005 6f0f03f83b wr: 중수도 상세 modal 냄새 2026-04-10 11:46:49 +09:00
mind-one005 c5df40a9ef wr: 지도점검결과 중수도 수정 2026-04-09 18:12:09 +09:00
mind-one005 c6838839e6 운영에는 빌드에 설정했음. 2026-04-08 16:01:13 +09:00
jsyang 9bbc3f521b Merge commit 'b42d518ecf3605c250aadbd9eb4f79e259aeb708' into iipbs/dm/dev 2026-04-08 11:02:53 +09:00
jsyang 68f2aec5f1 SITE_ID = DM 2026-04-08 09:35:25 +09:00
dongsilove b42d518ecf wr: headList 주석 2026-04-07 23:14:56 +09:00
mind-one005 cb8cc460f9 wr: 주석 추가 및 냄새 - 으로 나오도록 2026-04-07 18:12:21 +09:00
mind-one005 bc577e488f wr: tableBody 분리 외 2026-04-07 17:05:54 +09:00
mind-one005 f7c7305ba0 wr: 중수도 지도점검결과등록 2026-04-07 13:50:48 +09:00
mind-one005 dd5ff0f7f8 wr: 중수도시설 지도점검결과 등록 2026-04-06 18:21:15 +09:00
mind-one005 bd85a3c3aa wr: 파일명 잘리는 현상 2026-04-06 16:12:10 +09:00
mind-one005 ff0eeb2e40 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-04-06 15:02:49 +09:00
mind-one005 c9378a0048 wr: 계통도 파일다운로드명, 1건 삭제 2026-04-06 15:02:38 +09:00
jsyang bf95161243 Merge commit 'bd31f9c6f859b711c3d838b9341ff39ae9351280' into iipbs/sn/dev 2026-04-06 14:00:36 +09:00
jsyang bd31f9c6f8 dm_new > dm으로 덮어씀... 이전껀 백업 브렌치로 백업해놓음 2026-04-06 13:59:38 +09:00
mind-one005 c111aaa54f Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-04-06 10:19:14 +09:00
mind-one005 09de176d16 wr: modal toast 변경 2026-04-06 10:15:27 +09:00
jsyang c580141b71 Merge commit '5cb0719297bb84a4762095513ed55e9e9a001ce9' into iipbs/dm/dev 2026-04-06 09:15:57 +09:00
dongsilove 5cb0719297 wr: store toast 변경 2026-04-04 20:30:45 +09:00
dongsilove 1b6fb6f77d wr: (with-layout) toast 변경 2026-04-03 21:42:52 +09:00
mind-one005 5e5678f005 wr: 표준공사비 산정 toast > useConfirm으로 2026-04-03 18:07:09 +09:00
mind-one005 bbca5ac6f2 wr: confirmToast->useConfirm 2026-04-03 17:50:13 +09:00
mind-one005 b8ff15f187 wr: Toaster 상위로 위치 변경 2026-04-03 17:33:34 +09:00
jsyang 049ea098d0 수요신청현황 수정일자, 등록일자 추출 2026-04-03 13:29:20 +09:00
mind-one005 70b941c081 wr: 사용용도-WR018 2026-04-02 16:14:54 +09:00
jsyang f528c0e893 수요신청현황에서 수요정보 수정 기능 추가 2026-04-02 15:44:40 +09:00
jsyang f5f2a8f675 스타일재수정 2026-04-02 11:31:43 +09:00
mind-one005 21878892dc Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2026-04-02 11:06:11 +09:00
mind-one005 112634a8e3 dm: .env 수정 2026-04-02 11:06:03 +09:00
mind-one005 64d33bec6d wr: 로그인 전 cookie 삭제 외 2026-04-02 10:32:04 +09:00
mind-one005 4b1dd416b5 wr: 고정 width2 2026-04-01 18:12:28 +09:00
jsyang 6d28936a35 대민지원 빌드에러 수정 2026-04-01 17:58:14 +09:00
mind-one001 aca6e249b4 Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2026-04-01 17:37:46 +09:00
mind-one001 1dee2c4200 대민 수요정보등록 기능 수정 2026-04-01 17:03:16 +09:00
mind-one005 f2be91986c wr: 고정 width 2026-04-01 16:26:46 +09:00
jsyang fa64855574 middleware 원복 2026-04-01 16:24:15 +09:00
jsyang df7a66b2c7 후보지 선택 후 수요정보에 입력, 저장 시 넘어가는 데이터 테스트 필요 2026-04-01 15:31:37 +09:00
jsyang e7f4da2322 미들웨어 reuse-service수정 2026-04-01 14:37:16 +09:00
jsyang c551481c5b Merge commit '3efac843d465e79fa6a53ea1a8cb250d0d26cacb' into iipbs/dm/dev 2026-04-01 14:05:16 +09:00
jsyang b0f71f2239 들여쓰기 수정(개발서버 깃못불러와서 확인용) 2026-04-01 13:35:02 +09:00
jsyang 688ec0f88f 대민지원 후보지조회 base.css style적용 2026-04-01 13:22:14 +09:00
mind-one005 3efac843d4 sn: 개발환경 2026-04-01 11:02:23 +09:00
jsyang 3ed8b4ed6c url확인? 2026-03-31 22:08:03 +09:00
jsyang 2042076597 middleware 수정 2026-03-31 21:06:38 +09:00
jsyang 7088540ccc 빌드에러수정(변수타입 수정) 2026-03-31 20:24:44 +09:00
jsyang c279124c41 빌드에러수정(안쓰는거 제거) 2026-03-31 20:22:14 +09:00
jsyang fc03a19da1 수요정보등록 validation 제거(임시), 수요신청현황 수정버튼 임시비활성화 2026-03-31 20:18:45 +09:00
jsyang e566bc087b 유사사례 모달 수정 (초기조회 되도록) 2026-03-31 16:12:44 +09:00
jsyang 90eb5f3b04 수요정보 등록 후보지조회 제외 완료 2026-03-31 16:12:25 +09:00
mind-one005 7b9db8d879 Merge branch 'iipbs/sn/dev' 2026-03-31 13:43:47 +09:00
mind-one005 b1d556c6ee wr: 수요매칭 pageNation 주석처리 2026-03-31 13:32:30 +09:00
mind-one005 5e01305922 wr: 표준공사비 산정 comp 수정 2026-03-31 11:00:42 +09:00
rsone001 8015dc4b93 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-03-30 17:11:10 +09:00
rsone001 bb6274ef12 st: fix: 경로 에러 수정 2026-03-30 17:11:01 +09:00
chanii8 2805e382b3 [st] feat: 일반 검증 관리 -> 검증 항목 관리 탭으로 이동 2026-03-30 17:05:02 +09:00
chanii8 df4f3b296b Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-03-30 16:40:14 +09:00
rsone001 42632d96ad st: fix: 불필요 주석 삭제 2026-03-30 16:27:37 +09:00
rsone001 a73265eac6 st: feat: 수식 다이얼로그 컬럼 검색 추가 2026-03-30 16:26:36 +09:00
chanii8 1a82d549f5 [st] feat: HB 통계 검증 차트 수정 2026-03-30 11:52:30 +09:00
rsone001 28681969e2 st: fix: 입력단 api 수정 2026-03-30 10:06:42 +09:00
dongsilove f5f0f67798 wr: 후보지 조회 -feature 선택시 확대 제한 2026-03-28 20:46:09 +09:00
mind-one005 cbee407911 wr: 수요처 매칭 검토 readOnly 처리 외 2026-03-27 18:15:26 +09:00
chanii8 cc291243b0 [st] feat: IMR 이상치 데이터 그리드 컬럼 수정 2026-03-27 17:48:55 +09:00
chanii8 11ecd90d13 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2026-03-27 17:14:30 +09:00
mind-one005 57904427cf wr: 수요처관리 전화번호에 - 2026-03-27 17:07:50 +09:00
mind-one005 5372ef9d9c Merge branch 'iipbs/sn/dev' into iipbs/dm/dev 2026-03-27 14:36:57 +09:00
mind-one005 214bd9db4d wr: 후보지 조회지도 선택된 시설목록 수정 2026-03-27 14:31:40 +09:00
chanii8 7423b778ee [st] feat: HB 검증 이력 그리드 placeholderData 적용 2026-03-27 11:15:28 +09:00
chanii8 4bd67fcbb3 [st] feat: IMR 이상치 데이터 그리드 컬럼 필터링 적용 2026-03-27 11:10:39 +09:00
mind-one005 ce0e6e9d99 wr: 수요처매칭 용도 수정 2026-03-26 18:16:17 +09:00
rsone001 ca324251ad st: feat: 하수도보급률 집계표 조회, 기타 수정 2026-03-26 11:49:54 +09:00
mind-one005 41504e6e0f sn: 노후관로관리 1차 디자인 2026-03-26 10:53:43 +09:00
mind-one005 b40a974ba3 sn: md 추가 2026-03-26 09:45:36 +09:00
mind-one005 5f11b39810 sn: package.json @common/config 추가 2026-03-25 17:59:26 +09:00
chanii8 f706f07bc5 chore: eslint 버전 통일 2026-03-25 17:35:38 +09:00
mind-one005 c987f6beed wr: 수요처 수정 2026-03-25 17:23:48 +09:00
jsyang 4bcda65c05 빌드 에러 수정 2026-03-25 15:43:28 +09:00
chanii8 3f12cfd8c3 chore: fo, sn, wr 환경변수 수정 2026-03-25 14:44:32 +09:00
chanii8 abfa231493 [st] refactor: 로그인 페이지 auth 그룹으로 분기 처리 (client api 영향 범위에서 벗어나기 위함) 2026-03-25 14:22:48 +09:00
chanii8 601e99fbd5 refactor: 권한 인증 에러 처리 로직 2026-03-25 13:18:19 +09:00
mind-one005 c7b22b3e95 sn: 하수관로 html 2026-03-25 13:03:39 +09:00
jsyang a57c1a4a5c 수요정보 등록 기능 추가 2026-03-25 11:44:18 +09:00
mind-one005 7cb1a9ab6f wr: 도로명 입력시 추천 나타나고 결과주소선택시 시도,시군구 설정 2026-03-24 16:38:16 +09:00
mind-one005 c86dd2bb9e wr: 우편번호 관련 수정 2026-03-24 14:41:06 +09:00
mind-one005 645a7db2e5 wr: 점검현황 excel 수정 2026-03-23 18:06:31 +09:00
mind-one005 edf0d30b95 wr: commom -> common 2026-03-23 16:40:03 +09:00
mind-one005 bb8e3e5456 wr: 페이지이동시 선택 해제 2026-03-23 16:03:40 +09:00
622 changed files with 19733 additions and 17987 deletions
+3
View File
@@ -3,3 +3,6 @@ NEXT_PUBLIC_PORT=
NEXT_PUBLIC_PROXY_HOST=
NEXT_PUBLIC_SITE_ID=
NEXT_PUBLIC_SERVER_API_URL=
# 401 시 브라우저 리다이렉트 대상
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
+1 -1
View File
@@ -25,7 +25,7 @@
},
"devDependencies": {
"eslint": "^9.0.0",
"eslint-config-next": "15.4.0",
"eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
+1 -1
View File
@@ -52,7 +52,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
"eslint-config-next": "15.4.0",
"eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
+4 -10
View File
@@ -5,16 +5,15 @@ import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { isAtLoginRedirectUrl } from "@common/network";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
// 로그인 페이지에서는 인증 체크를 건너뜀
if (window.location.pathname === '/auth/login') {
if (isAtLoginRedirectUrl("/auth/login")) {
return;
}
@@ -35,13 +34,8 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
setUserInfo(null);
if (!isAtLoginRedirectUrl("/auth/login")) {
router.push("/auth/login");
}
}
+1 -1
View File
@@ -46,7 +46,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"eslint-config-next": "15.3.8",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
@@ -4,13 +4,17 @@ import { useEffect } from "react";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { getAuthLoginRedirectUrl, isAtLoginRedirectUrl } from "@common/network";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
if (isAtLoginRedirectUrl(getAuthLoginRedirectUrl())) {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -28,8 +32,6 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
await LoginApi.silentRefresh();
await authCheck();
}
})();
}, []);
+1 -1
View File
@@ -52,7 +52,7 @@
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
"eslint-config-next": "15.4.0",
"eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
+13 -12
View File
@@ -1,18 +1,24 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
import { isAtLoginRedirectUrl } from "@common/network";
import {
ENDPOINT,
AUTH_USER_ID,
AUTH_LOGIN_REDIRECT_URL,
} from "@/shared/config";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -30,14 +36,9 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
router.push("/auth/login");
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
}
}
})();
+2
View File
@@ -2,6 +2,8 @@ export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN =
+1 -1
View File
@@ -41,7 +41,7 @@
"@types/node": "^22.17.0",
"@types/webpack": "^5.28.5",
"eslint": "^9.0.0",
"eslint-config-next": "15.4.0",
"eslint-config-next": "15.3.8",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
+13 -14
View File
@@ -1,20 +1,24 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
import { isAtLoginRedirectUrl } from "@common/network";
import {
ENDPOINT,
AUTH_USER_ID,
AUTH_LOGIN_REDIRECT_URL,
} from "@/shared/config";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
@@ -32,14 +36,9 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
await authCheck();
} catch (error) {
console.error(error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
router.push("/auth/login");
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
}
}
})();
+6 -2
View File
@@ -3,10 +3,14 @@ export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const NEXT_PUBLIC_DC_SERVICE_API_URL = process.env.NEXT_PUBLIC_DC_SERVICE_API_URL || `http://localhost:${PORT}`;
export const NEXT_PUBLIC_DC_SERVICE_API_URL =
process.env.NEXT_PUBLIC_DC_SERVICE_API_URL || `http://localhost:${PORT}`;
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
// Flask API URL
export const FLASK_API_URL = process.env.NEXT_PUBLIC_FLASK_API_URL || "http://localhost:5000";
export const FLASK_API_URL =
process.env.NEXT_PUBLIC_FLASK_API_URL || "http://localhost:5000";
//export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "DC";
export const SITE_ID = "DC";
+4 -4
View File
@@ -1,12 +1,13 @@
NEXT_PUBLIC_SITE_ID = DM
NEXT_PUBLIC_PORT = 3015
NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PORT = 3005
NEXT_PUBLIC_PROXY_HOST = http://localhost:3005
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_PROXY_HOST = http://localhost:3015
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
# NEXT_PUBLIC_FO_SERVER_API_URL = http://192.168.1.222:8883/operation-service/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
@@ -19,7 +20,6 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
+10 -8
View File
@@ -1,23 +1,25 @@
NEXT_PUBLIC_SITE_ID = DM
NEXT_PUBLIC_PORT = 11035
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11035
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_PORT = 11315
NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
#NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
#NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
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_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service # 개발 서버 API
NEXT_PUBLIC_GATEWAY_URL=http://con.mind-one.co.kr:11083 # 개발 서버 API
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
+4 -4
View File
@@ -8,16 +8,16 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_PROXY_HOST = https://devreuse.hasuon.or.kr
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
NEXT_PUBLIC_WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/sewerage-service
NEXT_PUBLIC_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_NGII_URL = http://172.29.143.87/poiact
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_GATEWAY_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
NEXT_PUBLIC_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do
-39
View File
@@ -1,39 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+38
View File
@@ -0,0 +1,38 @@
## Project
This repository contains a Next.js web application.
Main technologies:
- Next.js
- TypeScript
- pnpm workspace
- turborepo
이 저장소는 pnpm workspace + Turborepo 기반 monorepo이다.
- 워크스페이스 범위는 `apps/*`, `apps/common/*`, `packages/*`, `packages/common/*`
- 루트 스크립트는 `turbo run build`, `turbo run dev` 를 사용한다
- `build` 작업은 의존 패키지의 `build` 를 먼저 수행한다 (`dependsOn: ["^build"]`)
- `dev` 는 캐시하지 않는 지속 실행 작업이다
- 변경 범위만 선택 실행할 수 있으므로, 전체 빌드보다 `--filter` 사용을 우선한다
## Encoding
- UTF-8
- 한글이 깨지지 않도록 주의할 것
## 주석 comment
- 한글 주석을 삭제하지 말 것
## Monorepo structure
apps/
_template
as
cm
cs
dc
dm
dm_new
fo
sn
st
wr
-16
View File
@@ -1,16 +0,0 @@
"use client";
import Base from "../base";
import "../globals.css";
import { StoreProvider } from "../store";
export default function BaseLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<StoreProvider>
<Base>{children}</Base>
</StoreProvider>
);
}
@@ -1,209 +0,0 @@
"use client"
import { Table, TableBody, TableCell, TableRow } from '@common/components/ui/table';
import { useDmStore } from '@/app/store';
import { ContentsDiv, Title, UiModal, UiTable } from '@common/business_components/ui';
import ListDiv from '@common/business_components/ui/list_div';
import { useEffect, useState } from 'react';
import { Toaster } from 'react-hot-toast';
import SdmLogin from '../sdmLogin/page';
import type { DemandCompBizType } from '@/app/store/sdmMng';
import TransValueFormat from '@/app/common/transValueFormat';
import { useRouter } from 'next/navigation';
const TABLE_STYLES = {
header: "text-center border border-[var(--border)] bg-[rgb(234,243,251)] text-[rgb(100,119,132)] whitespace-pre-line",
cell: "text-right whitespace-pre-line",
fullWidth: "text-center border border-[var(--border)] bg-[rgb(234,243,251)] text-[rgb(100,119,132)] whitespace-pre-line",
table: "fixed w-full"
} as const;
type SdmApplyProps = {
sdmNo : string;
}
export default function SdmApplyList({}: SdmApplyProps) {
const router = useRouter();
const {getSdmInfoStatusList, Sdm, Common} = useDmStore((state) => state);
const {successLogin, sdmInfoStatusList, upHeadSdmInfoStatus} = Sdm;
const {selectSidoData, selectSigunData} = Common;
const [isLogin, setIsLogin] = useState(false);
const [isLoginStorageInfo, setIsLoginStorageInfo] = useState(false);
const [sdmApplyDetail, setSdmApplyDetail] = useState<DemandCompBizType>({} as DemandCompBizType);
const [transformedList, setTransformedList] = useState<DemandCompBizType[]>([]);
useEffect(() => {
console.log('sessionStorage : ',sessionStorage.getItem('user'));
const user = sessionStorage.getItem('user');
if (user) {
setIsLoginStorageInfo(true);
}
}, []);
useEffect(() => {
if (successLogin || isLoginStorageInfo) {
setIsLogin(true);
const user = sessionStorage.getItem('user');
if (user) {
try {
const userData = JSON.parse(user);
getSdmInfoStatusList(userData.dmdInfoId);
} catch (error) {
console.error('Error parsing user data:', error);
}
}
}
}, [successLogin, getSdmInfoStatusList, isLoginStorageInfo]);
// 데이터 변환을 위한 별도의 useEffect
useEffect(() => {
if (sdmInfoStatusList.length > 0) {
const newTransformedList = sdmInfoStatusList.map((item: DemandCompBizType) => ({
...item,
ctpvCd: selectSidoData.find(sido => sido.val === item.ctpvCd?.trim())?.text || '',
sggCd: selectSigunData.find(sigun => sigun.val === item.sggCd?.trim())?.text || '',
}));
setTransformedList(newTransformedList);
}
}, [sdmInfoStatusList, selectSidoData, selectSigunData]);
const onClickSdmStatus = (index: number | undefined) => {
if (index !== undefined) {
const selectedStatus = transformedList[index];
if (selectedStatus) {
setSdmApplyDetail(selectedStatus);
}
}
}
return (
<>
<Title title="수요신청현황" />
<ContentsDiv>
<ListDiv>
<div className="overflow-y-scroll h-[250px]">
<div className="h-[110%] w-[99%]">
<UiTable
headName='수요정보 신청현황'
tableData={transformedList}
headlist={upHeadSdmInfoStatus}
pageSize={0}
total={0}
cellClick={(index) => onClickSdmStatus(index)}
onPageChange={() => {}}
/>
</div>
</div>
</ListDiv>
<ListDiv>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">수요신청 상세현황</h3>
</div>
<div className={`card !m-[1.2rem] !p-0r`}>
<Table variant="secondary" style={{ tableLayout: 'fixed', width: '100%' }}>
<TableBody>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>요구시기</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.dmndTmng?.toString()}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>요구수량(m³/일)</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.dmndQty} format="number"/>}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>사용용도</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.useUsgCd}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>희망단가(원/m³)</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.wntUntprc} format="number"/>}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={6} className={TABLE_STYLES.fullWidth}>요구수량</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>총대장균군수(개/100ml)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{ sdmApplyDetail?.grssClfg!=-1?<TransValueFormat value={sdmApplyDetail?.grssClfg ?? ''} format="number"/>:''} {sdmApplyDetail?.grssClfg?sdmApplyDetail.grssClfg!=-1? '이하':'불검출':'-'} </TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>결합잔류염소(mg/L)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.combResdch ?? ''} format="number"/>} {sdmApplyDetail?.combResdch?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>탁도(NTU)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.trbt ?? ''} format="number"/>} {sdmApplyDetail?.trbt?'이하':'-'}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>생물학적 산소요구량(BOD)(mg/L)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.blgcOxygDmndQnt ?? ''} format="number"/>} {sdmApplyDetail?.blgcOxygDmndQnt?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>냄새</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.smll} format="number"/>} {sdmApplyDetail?.smll?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>색도(도)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.crmty ?? ''} format="number"/>} {sdmApplyDetail?.crmty?'이하':'-'}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>총질소(T-N)(mg/L)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.tn ?? ''} format="number"/>} {sdmApplyDetail?.tn?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>총인(T-P)(mg/L)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.tph ?? ''} format="number"/>} {sdmApplyDetail?.tph?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>수소이온농도(pH)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.ph} format="number"/>}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>염화물(mgCL/L)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.clIonQnt ?? ''} format="number"/>} {sdmApplyDetail?.clIonQnt?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>전기전도도(㎲/cm)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.cdtnv ?? ''} format="number"/>} {sdmApplyDetail?.cdtnv?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>비고</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{sdmApplyDetail?.rmrk}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={6} className={TABLE_STYLES.fullWidth}>공급처 후보지​</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>후보지1</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{ sdmApplyDetail?.splyCndtList?.[0]?.ruseUtztnFcltNm ?? ""}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>후보지2</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{sdmApplyDetail?.splyCndtList?.[1]?.ruseUtztnFcltNm ?? ""}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>후보지3</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{sdmApplyDetail?.splyCndtList?.[2]?.ruseUtztnFcltNm ?? ""}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</ListDiv>
<ListDiv>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">수요신청 진행상태</h3>
</div>
<div className={`card !m-[1.2rem] !p-0r`}>
<Table variant="secondary" style={{ tableLayout: 'fixed', width: '100%' }}>
<TableBody>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>진행상태</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.status?.toString()}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>공급처매칭결과</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.ruseUtztnFcltNm??''}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</ListDiv>
</ContentsDiv>
{!isLogin?
<UiModal
body={<SdmLogin/>}
bodyClass="sm:max-w-[90vw] w-[530px] h-[430px]"
open={!isLogin}
onOpenChange={(open) => {
if(!open){
setIsLogin(false)
router.push('/');
}
}}
noHeaderYn="Y"
noFooterYn="Y"
/>
:null}
<Toaster position="top-center" />
</>
);
}
@@ -1,166 +0,0 @@
"use client"
import { useDmStore } from '@/app/store';
import type { MoneyDetailType, SimilarCaseType } from '@/app/store/sdmMng';
import { SelectBox, Title, UiTable } from '@common/business_components/ui';
import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan';
import { Table, TableBody, TableCell, TableHeader, TableRow } from '@common/components/ui/table';
import { useEffect, useMemo, useState } from 'react';
type modalType = {
sply: number | 0;
usageCd: string | '';
money: number | 0;
};
const searchUseTypeList = [
{ text: '공급량', val: 'S' },
{ text: '사용용도', val: 'U' },
];
export default function ModalSimilarPage({ sply = 0, usageCd = '', money = 0 }: modalType) {
const { Sdm, getSimilarCaseList } = useDmStore((state) => state);
const [searchUseType, setSearchUseType] = useState<{text: string, value: string}>({text: '공급량', value: 'S'});
const [moneyDetailList, setMoneyDetailList] = useState<MoneyDetailType[]>([]);
const {
upHeadSimilarCaseList,
similarCaseList,
upHeadMoneyDetailList,
} = Sdm;
const rowSpans = useMemo(() => {
const spans: { [key: number]: number } = {};
let currentValue = '';
let currentSpan = 0;
let startIndex = 0;
similarCaseList.forEach((item: SimilarCaseType, index: number) => {
if (item.ruseBizNm === currentValue
|| item.bizBgngDate === currentValue
|| item.bizEndDate === currentValue
|| item.reqBizReuseFcltCst === currentValue) {
currentSpan++;
spans[startIndex] = currentSpan;
} else {
currentValue = item.ruseBizNm;
currentSpan = 1;
startIndex = index;
spans[index] = 1;
}
});
return spans;
}, [similarCaseList]);
const handleCellClick = (index: number) => {
setMoneyDetailList([{
reqBizGrssCst: similarCaseList[index]?.reqBizGrssCst ?? '',
reqBizReuseFcltCst: similarCaseList[index]?.reqBizReuseFcltCst ?? '',
reqBizPplCst: similarCaseList[index]?.reqBizPplCst ?? '',
reqBizArchCst: similarCaseList[index]?.reqBizArchCst ?? '',
reqBizMchnCst: similarCaseList[index]?.reqBizMchnCst ?? '',
reqBizMtrngCst: similarCaseList[index]?.reqBizMtrngCst ?? '',
reqBizGisCst: similarCaseList[index]?.reqBizGisCst ?? '',
reqBizCctvCst: similarCaseList[index]?.reqBizCctvCst ?? '',
reqBizCmcnCst: similarCaseList[index]?.reqBizCmcnCst ?? '',
reqBizWstCst: similarCaseList[index]?.reqBizWstCst ?? '',
reqBizDesignCst: similarCaseList[index]?.reqBizDesignCst ?? '',
reqBizSpvsCst: similarCaseList[index]?.reqBizSpvsCst ?? '',
reqBizSbsdCst: similarCaseList[index]?.reqBizSbsdCst ?? '',
reqBizEtc: similarCaseList[index]?.reqBizEtc ?? '',
prcmPlanNlep: similarCaseList[index]?.prcmPlanNlep ?? '',
prcmPlanLcep: similarCaseList[index]?.prcmPlanLcep ?? '',
prcmPlanBrdnCst: similarCaseList[index]?.prcmPlanBrdnCst ?? '',
prcmPlanEcqrRt: similarCaseList[index]?.prcmPlanEcqrRt ?? '',
prcmPlanGrssCst: similarCaseList[index]?.prcmPlanGrssCst ?? '',
clclnNlep: similarCaseList[index]?.clclnNlep ?? '',
clclnLcep: similarCaseList[index]?.clclnLcep ?? '',
clclnBrdnCst: similarCaseList[index]?.clclnBrdnCst ?? '',
clclnEcqrRt: similarCaseList[index]?.clclnEcqrRt ?? '',
clclnGrssCst: similarCaseList[index]?.clclnGrssCst ?? ''
}]);
}
useEffect(() => {
const type = searchUseType.value;
getSimilarCaseList(type, type === 'S' ? sply : type === 'U' ? usageCd : money);
}, [getSimilarCaseList, money, searchUseType, sply, usageCd]);
return (
<div className="align-top w-[1360px] h-full">
<Title title="유사사례 조회" />
<div className="overflow-x-auto h-[480px] w-full mt-8" tabIndex={0} aria-label="유사사례 테이블 영역">
<div className="w-[150%]">
<div className="card-header justify-self-start">
<h3 className="text-2xl font-medium text-label">유사사례</h3>
<SelectBox
tabIndex={0}
aria-label="검색 기준 선택"
selectArray={searchUseTypeList}
value={searchUseType.value}
onSelectValue={(val) => {
setSearchUseType({text: searchUseTypeList.find(item => item.val === val)?.text ?? '', value: val.toString()});
}}
className="w-[140px] ml-20"
label=""
selectClass="w-[140px]"
/>
</div>
<div className={`card !m-[1.2rem] !p-0r`}>
<Table variant="secondary">
<TableHeader className="font-bold">
<HeadMakeColSpan upHeadList={upHeadSimilarCaseList} />
</TableHeader>
<TableBody className='text-center'>
{similarCaseList.length > 0 ? similarCaseList?.map((el: SimilarCaseType, index) => (
<TableRow key={`similar-case-row-${index}-${el.ruseBizNm}-${el.bizBgngDate}`}>
{upHeadSimilarCaseList.map((head) => (
head.id && head.id.length > 1 && (
<TableCell
tabIndex={head.id === 'someClickableId' ? 0 : undefined}
role="button"
aria-label={`유사사례 ${head.id} 클릭 시 상세 정보 표시`}
className={`${head.align === 'right' ? 'text-right' : head.align === 'center' ? 'text-center' : typeof el[head.id as keyof SimilarCaseType] === 'number' ? 'text-right' : 'text-center'} cursor-pointer`}
key={`similar-case-cell-${head.id}-${index}-${el.ruseBizNm}`}
rowSpan={(head.id === 'businessName' || head.id ==='businessDuration' || head.id === 'money') ? rowSpans[index] : undefined}
style={(head.id === 'businessName' || head.id === 'businessDuration' || head.id === 'money') && index > 0 && similarCaseList[index - 1]?.ruseBizNm === el.ruseBizNm ? { display: 'none' } : undefined}
onClick={() => handleCellClick(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleCellClick(index);
}
}}
>
{typeof el[head.id as keyof SimilarCaseType] !== 'number'
? String(el[head.id as keyof SimilarCaseType] ?? '')
: String(el[head.id as keyof SimilarCaseType]).includes('.')
? String(el[head.id as keyof SimilarCaseType]).split('.').map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
).join('.')
: String(el[head.id as keyof SimilarCaseType] ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
</TableCell>
)
))}
</TableRow>
)) : <></>}
</TableBody>
</Table>
</div>
</div>
</div>
<div className="overflow-x-auto h-[270px] w-full mt-8 overflow-y-hidden">
<div className="w-[130%]">
<UiTable
headName={"사업비 상세정보"}
tableData={moneyDetailList}
headlist={upHeadMoneyDetailList}
pageSize={0}
total={0}
cellClick={undefined}
onPageChange={() => {}}
/>
</div>
</div>
</div>
)
}
@@ -1,294 +0,0 @@
"use client";
/**
* 수요정보등록 화면
*
* 🔗 사용 API
* - 수요정보등록: POST /reuseFc/request/demandInfo
*/
import { useDmStore } from "@/app/store";
import { sdmInfoFormSchema, type DemandCompBizType, type SdmInfoFormData } from "@/app/store/sdmMng";
import { SdmInfoRegForm } from "@/components/ui/sdmInfoInsertForm/component";
import { UiModal } from "@common/business_components/ui/dialog_ui";
import { Button } from "@common/components/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { FormProvider, useForm, type SubmitHandler } from "react-hook-form";
import toast, { Toaster } from "react-hot-toast";
import SdmLogin from "../sdmLogin/page";
function SdmInfoInsertContent() {
const searchParams = useSearchParams();
const { insertSdmInfoData, Sdm, setSubsInfo } = useDmStore((state) => state);
const { successLogin } = Sdm;
const [ismoneyDisabled, setIsmoneyDisabled] = useState(false);
const formSchema = sdmInfoFormSchema;
const [isLogin, setIsLogin] = useState(false);
const [facilityName, setFacilityName] = useState<string>("");
const [defaultValues, setDefaultValues] = useState<SdmInfoFormData>({
sdmNm: "",
demandYmd: new Date(),
demandQnty: "",
usePurpose: "",
money: "",
/**요구수질 */
reqWaterQlty: "",
grssClfg: null,
tn: null,
combResdch: null,
tph: null,
trbt: null,
ph: null,
bod: null,
/**컬럼확인필요 */
clIonQnt: null,
smll: "",
cdtnv: null,
crmty: null,
rmrk: "",
/** 후보지 제목 */
candidateTitle: [
{ ruseUtztnFcltNm: "", ruseUtztnFcltCd: "" },
{ ruseUtztnFcltNm: "", ruseUtztnFcltCd: "" },
{ ruseUtztnFcltNm: "", ruseUtztnFcltCd: "" },
],
});
useEffect(() => {
const user = sessionStorage.getItem("user");
setDefaultValues((prev) => ({
...prev,
sdmNm: searchParams.get("dmdCoNm") || "",
}));
if (searchParams.get("dmdInfoId")) {
// const data = {
// dmdCoNm: searchParams.get("dmdCoNm") || '',
// ctpvCd: searchParams.get("ctpvCd") || '',
// sggCd: searchParams.get("sggCd") || '',
// dmdInfoId: searchParams.get("dmdInfoId") || '',
// daddr: searchParams.get("daddr") || '',
// telno: searchParams.get("telno") || '',
// }
// sessionStorage.setItem("user", JSON.stringify(data || {}));
}
//sessionStorage.setItem("user", JSON.parse(user||'') || "");
setIsLogin(user || searchParams.get("dmdInfoId") ? true : false);
}, [searchParams]);
useEffect(() => {
const local = localStorage.getItem("user");
if (local) {
setIsLogin(true);
}
setSubsInfo([]); // subsInfo 초기화 후보지 처음 화면 들어올때 초기화(스토어내 객체만)
}, [setSubsInfo]);
const form = useForm<SdmInfoFormData>({
resolver: zodResolver(formSchema),
defaultValues,
mode: "onSubmit",
});
const subsInfo = useDmStore((state) => state.map.subsInfo);
useEffect(() => {
//map.subsInfo 스토어 객체 값이 변경되면 해당 값이 폼에 자동으로 들어가도록 수정 대신 값이 있어야함
if (subsInfo.length > 0) {
form.setValue("candidateTitle", subsInfo);
}
}, [subsInfo, form]);
//후보지 팝업 값 셋팅
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "SET_SUBS_INFO") {
setSubsInfo(event.data.payload); // zustand로 값 설정
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [setSubsInfo]);
// 로그인 성공 시 모달 닫기 및 폼 업데이트
useEffect(() => {
if (successLogin) {
setIsLogin(true);
const user = sessionStorage.getItem("user");
if (user) {
try {
const userData = JSON.parse(user);
if (userData.dmdCoNm) {
setFacilityName(userData.dmdCoNm);
form.setValue("sdmNm", userData.dmdCoNm); // 수요처명(회사명)
}
} catch (error) {
console.error("Error parsing user data:", error);
}
}
}
}, [successLogin, form]);
useEffect(() => {
console.log("Form state changed:", form.formState.errors);
}, [form.formState.errors]);
// 체크박스 상태가 변경될 때마다 money 필드의 에러를 초기화
useEffect(() => {
if (ismoneyDisabled) {
form.clearErrors("money");
form.setValue("money", "");
}
}, [ismoneyDisabled, form]);
const router = useRouter();
const onSubmit: SubmitHandler<SdmInfoFormData> = async (data) => {
try {
const dmSubsInfo = data.candidateTitle?.filter(
(item) => item.ruseUtztnFcltNm && item.ruseUtztnFcltCd
);
// Transform form data to madata.candidateTitletch API schema
const apiData: DemandCompBizType = {
dmdInfoId:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdInfoId?.toString() ||
searchParams.get("dmdInfoId") + (searchParams.get("bznmTotNo") || ""),
dmdInfoFk:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdInfoId?.toString() ||
searchParams.get("dmdInfoId") + (searchParams.get("bznmTotNo") || ""),
dmdCoNm:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdCoNm?.toString() || data.sdmNm,
ctpvCd:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.ctpvCd?.toString() ||
searchParams.get("ctpvCd") ||
"",
sggCd:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.sggCd?.toString() ||
searchParams.get("sggCd") ||
"",
daddr:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.daddr?.toString() ||
searchParams.get("daddr") ||
"",
telno:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.telno?.toString() ||
searchParams.get("telno") ||
"",
rgtr:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.rgtr?.toString() ||
searchParams.get("rgtr") ||
"",
useUsgCd: data.usePurpose || "",
wntUntprc: Number(data.money) || "",
dmndTmng: data.demandYmd || new Date(),
dmndQty: Number(data.demandQnty) || "",
grssClfg: data.grssClfg === null ? -1 : Number(data.grssClfg),
tn: Number(data.tn) || null,
combResdch: Number(data.combResdch) || null,
tph: Number(data.tph) || null,
trbt: Number(data.trbt) || null,
ph: data.ph || "",
blgcOxygDmndQnt: Number(data.bod) || null,
clIonQnt: Number(data.clIonQnt) || null,
smll: data.smll || "",
cdtnv: Number(data.cdtnv) || null,
crmty: Number(data.crmty) || null,
rmrk: data.rmrk || "",
bzmnNo: "",
dmdAplyPswd: "",
index: 0,
reqDt: "",
dmdInfoSn: 0,
status: "",
splyCndtList: dmSubsInfo || [],
};
await insertSdmInfoData(apiData);
setTimeout(() => {
router.push("/");
}, 1000); // 0.8초 후 이동
} catch (error) {
console.error("Error submitting form:", error);
}
};
return (
<div className="min-h-screen bg-[#F2F2F2]">
<div className="container mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 underline">
수요정보 등록
</h1>
</div>
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow-md p-6">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<SdmInfoRegForm
form={form}
onCheckboxChange={setIsmoneyDisabled}
facilityName={facilityName}
/>
<div className="flex justify-center pt-4">
<Button
color="sky"
size={"sm"}
type="submit"
className="w-40 font-semibold py-2 px-4 rounded-md "
>
수요정보 등록
</Button>
</div>
</form>
</FormProvider>
</div>
</div>
{!isLogin ? (
<UiModal
body={<SdmLogin />}
bodyClass="sm:max-w-[90vw] w-[530px] h-[430px]"
open={!isLogin}
onOpenChange={(open) => {
if (!open) {
toast.dismiss();
router.push("/");
}
}}
noHeaderYn="Y"
noFooterYn="Y"
/>
) : null}
<Toaster position="top-center" />
</div>
);
}
export default function MyForm() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SdmInfoInsertContent />
</Suspense>
);
}
@@ -1,67 +0,0 @@
"use client";
import { useDmStore } from "@/app/store";
import SdmInsertForm from '@common/business_components/ui/sdmInsertForm';
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef } from "react";
import { Toaster } from "react-hot-toast";
export default function MyForm() {
const { Common, initializeCommonData, getDemandCompList, insertSdmData, Sdm } = useDmStore((state) => state);
const { selectSidoData, selectSigunData } = Common;
const { demandCompList, successNextPage } = Sdm;
const router = useRouter();
const formDataRef = useRef<DemandCompType | null>(null);
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
useEffect(() => {
if (successNextPage && formDataRef.current) {
const data = formDataRef.current;
console.log(data);
const queryParams = new URLSearchParams({
dmdCoNm: data.dmdCoNm || '',
ctpvCd: data.ctpvCd || '',
sggCd: data.sggCd || '',
dmdInfoId: data.dmdInfoId || '',
daddr: data.daddr || '',
telno: data.telno || '',
});
router.push(`/pages/sdmMng/sdmInfoInsert?${queryParams.toString()}`);
}
}, [successNextPage, router]);
const callback = useCallback((searchText: string, pageCnt: number) => {
searchText = searchText.trim();
if (searchText.length > 0) {
getDemandCompList(searchText, pageCnt);
}
}, [getDemandCompList]);
const handleInsertSdmData = useCallback((data: import('@common/business_components/ui/sdmInsertForm/form').DemandCompType) => {
const convertedData: DemandCompType = {
...data,
};
formDataRef.current = convertedData;
insertSdmData(convertedData)
}, [insertSdmData]);
return (
<div className="min-h-screen bg-[#F2F2F2]">
<div className="pt-12">
<SdmInsertForm
selectSidoData={selectSidoData}
selectSigunData={selectSigunData}
dataList={demandCompList}
rtnValue={callback}
rtnInputData={handleInsertSdmData}
openNetwork={true}
/>
<Toaster position="top-center" />
</div>
</div>
);
}
@@ -1,18 +0,0 @@
'use client'
import { SdmLoginForm } from '@/components/ui/sdmLoginForm/component';
export default function SdmLogin() {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 underline">
수요처 로그인
</h1>
</div>
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow-md p-6">
<SdmLoginForm />
</div>
</div>
);
}
@@ -1,22 +0,0 @@
"use client";
/**
* 수요처 로그아웃 화면
*
* 🔗 사용 API
*
*/
import { useDmStore } from "@/app/store";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function SdmLogout() {
const router = useRouter();
const { actionLogout } = useDmStore((state) => state);
useEffect(() => {
actionLogout();
router.push("/pages/sdmMng/sdmInfoInsert");
}, [actionLogout, router]);
return null;
}
@@ -1,339 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
const NAVS = [
{ id: 'card1', label: '물재이용이란' },
{ id: 'card2', label: '물재이용 필요성' },
{ id: 'card3', label: '빗물 재이용' },
{ id: 'card4', label: '중수 재이용' },
{ id: 'card5', label: '하수 재이용' },
{ id: 'card6', label: '물재이용 목표' },
];
export default function WrAboutPage() {
const [activeIdx, setActiveIdx] = useState(0);
const navRefs = useRef<(HTMLLIElement | null)[]>([]);
const cardRefs = useRef<(HTMLElement | null)[]>([]);
// 클릭 시 이동
const handleNavClick = (idx: number) => {
setActiveIdx(idx);
const targetCard = cardRefs.current[idx];
if (targetCard) {
targetCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
// 키보드 이벤트 처리
const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNavClick(idx);
}
};
useEffect(() => {
const observerOptions = {
root: null,
rootMargin: '-200px 0px 0px 0px', // 상단에서 200px 지점을 기준으로
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const idx = cardRefs.current.findIndex(ref => ref === entry.target);
if (idx !== -1) {
setActiveIdx(idx);
}
}
});
}, observerOptions);
// 각 카드 요소를 관찰
const cardsSnapshot = [...cardRefs.current];
cardsSnapshot.forEach((card) => {
if (card) {
observer.observe(card);
}
});
return () => {
cardsSnapshot.forEach((card) => {
if (card) {
observer.unobserve(card);
}
});
};
}, []);
return (
<div className='bg-[#F2F2F2]'>
<main className="relative w-[950px] mx-auto pt-[100px] font-sans">
{/* 사이드바 */}
<nav className="fixed left-[290px] top-[195px] w-[210px] z-50" aria-label="물재이용 정보 섹션 내비게이션">
<ul role="list" className="relative space-y-6">
{NAVS.map((nav, idx) => (
<li
key={nav.id}
ref={(el) => { navRefs.current[idx] = el; }}
role="listitem"
tabIndex={0}
aria-current={idx === activeIdx ? 'true' : 'false'}
aria-label={`현재 위치: ${nav.label}`}
onClick={() => handleNavClick(idx)}
onKeyDown={(e) => handleKeyDown(e, idx)}
className={`font-NotoSansKR nav-item relative pl-6 text-2xl font-bold text-[#383838] cursor-pointer transition-all duration-300
after:content-[''] after:absolute after:top-6 after:left-[0.5rem] after:w-[2px] after:h-[24px] after:bg-[#C5C5C5] last:after:hidden
${idx === activeIdx ? 'text-4xl font-bold text-[#383838]' : ''}`}
>
<span className={`absolute left-[0.5rem] top-1 w-4 h-4 rounded-full -translate-x-1/2 transition-all duration-300
${idx === activeIdx ? 'bg-blue-500 border-blue-500' : 'bg-white border-gray-400 border'}`}></span>
{nav.label}
</li>
))}
</ul>
</nav>
<article ref={(el) => { cardRefs.current[0] = el; }} className="card-wrap pl-[100px] mb-24 !pt-0" id="card1" aria-labelledby="card1-title">
<h2 id="card1-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용이란
</h2>
<section aria-labelledby="card1-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card1-desc" className="font-NotoSansKR text-[#269DDA] text-5xl font-bold mb-8">물 재이용이 무엇인가요?</h1>
<p className="font-NotoSansKR text-[#231815] text-2xl mb-10 leading-relaxed">
물 재이용은 빗물, 오수, 하수처리수, 폐수처리수 및 발전소 온배수를<br />
물 재이용시설을 이용하여 처리하고, 그 처리된 물을 생활, 공업, 농업, 조경,<br />
하천 유지 등의 용도로 이용하는 것을 말합니다.
</p>
<Image
src="/img/wrAbout/img_01_1.png"
alt="이 이미지는 물 재이용 시스템의 개요를 나타냅니다. 가정, 공공기관, 하수처리장 등에서 발생한 생활하수, 빗물, 하수처리수를 물 재이용 시설에서 처리한 후, 청소나 화장실, 농업, 살수차, 하천 유지, 공장, 공원 및 골프장 등 다양한 용도로 활용하는 과정을 도식화한 것입니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-8 rounded"
/>
<p className="font-NotoSansKR text-center font-normal text-2xl">
환경부는 2011년에 &apos;물의 재이용 촉진 및 지원에 관한 법률&apos;을 제정하여<br />
한국환경공단과 함께 물 재이용 확대 및 촉진을 위해 노력하고 있습니다.
</p>
</section>
</article>
{/* 카드2 */}
<article ref={(el) => { cardRefs.current[1] = el; }} className="card-wrap pl-[100px] mb-24" id="card2" aria-labelledby="card2-title">
<h2 id="card2-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 필요성
</h2>
<section aria-labelledby="card2-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card2-desc" className="font-NotoSansKR text-[#D63F1F] text-5xl font-semibold mb-8">물 재이용, 왜 필요한가요?</h1>
<p className="font-NotoSansKR text-2xl font-normal mb-10 leading-relaxed">
빗물, 중수, 하수처리수는 지속적으로 이용 가능한 순환자원입니다.<br />
물 재이용을 통한 효율적인 수자원 활용은 물 스트레스국가인 우리나라에서<br />가뭄대응능력 향상 및 하천수질보전을 위해 반드시 필요한 일입니다.
</p>
<Image
src="/img/wrAbout/img_02_1.png"
alt="이 이미지는 기후변화로 인한 온도 상승 추이를 설명한다. 왼쪽에는 지구 그림과 온도계가 함께 표시되어 있으며, 지구 평균 기온은 지난 100년간 약 0.9℃ 상승했음을 나타낸다. 오른쪽에는 대한민국 지도와 함께 온도계가 있으며, 지난 100년간 약 1.8℃ 상승했음을 보여준다. 이는 지구 평균보다 2배 높은 수치이며, 자료 출처는 「기후변화 평가보고서 2020, 환경부·기상청」이다."
width={800}
height={400}
className="w-[90%] mx-auto mb-8 w-[80%]"
/>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">
- 우리나라의 기후변화 진행속도는 세계 평균을 상회하고 있습니다.<br />
- 기후변화로 인한 국지적인 가뭄은 더욱 자주, 더욱 심하게 일어나고 있으며, 우리의 물 안보를 위협하고 있습니다.
</p>
<Image
src="/img/wrAbout/img_02_2.png"
alt="세계 평균 1인당 강수량은 15,044m³이며, 대한민국은 2,546m³로 약 1/6 수준입니다. 계절별 강수량은 봄 10%, 여름 70%, 가을 15%, 겨울 5%입니다. 이는 여름철에 강수량이 집중되는 문제를 나타냅니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-8"
/>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">
- 우리나라의 1인당 강수량은 2,546㎥ 으로 세계 평균 15,044㎥의 약 1/6 수준입니다.<br />
- 우리나라 강수량의 70%는 여름철에 집중되어 있어 이용 가능한 수자원확보가 어렵습니다.
</p>
<Image
src="/img/wrAbout/img_02_3.png"
alt="물 재이용은 환경을 보호하며 지속 가능한 발전이 가능하게 하고, 안정적인 수자원 확보, 깨끗하고 경제적인 공업용수 공급, 하천 수질 개선을 통한 생태계 회복에 기여합니다."
width={800}
height={400}
className="w-[93%] mx-auto"
/>
</section>
</article>
{/* 카드3 */}
<article ref={(el) => { cardRefs.current[2] = el; }} className="card-wrap pl-[100px] mb-24" id="card3" aria-labelledby="card3-title">
<h2 id="card3-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
빗물 재이용
</h2>
<section aria-labelledby="card3-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card3-desc" className="font-NotoSansKR leading-snug text-[#269DDA] text-5xl font-bold mb-6">친환경 수자원 빗물,<br />편리하게 사용할 수 있어요!</h1>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">가뭄을 극복하고 홍수를 예방하기 위해<br />비가 내릴 때 빗물을 모아두었다가 사용합니다.</p>
<Image
src="/img/wrAbout/img_03_1.png"
alt="주택 지붕에 내린 빗물을 저장조에 모아 조경용수, 화장실용수, 세척·살수용수로 재활용하는 과정을 보여줍니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-6"
/>
<Image
src="/img/wrAbout/img_03_2.png"
alt="빗물이용"
width={200}
height={100}
className="mb-5 w-[20%]"
/>
<p className="font-NotoSansKR text-2xl leading-relaxed">
- 의무 설치대상: 지붕면적 1,000㎡ 이상의 운동장, 체육관, 공공업무시설 등<br />
- 설 치 기 준: 집수시설, 처리시설, 빗물 저류조(지붕 집수면적 x 0.05m 이상)<br />
- 활 용 용 도: 골프장 등의 조경용수, 화장실용수, 세척·살수용수 등
</p>
</section>
</article>
{/* 카드4 */}
<article ref={(el) => { cardRefs.current[3] = el; }} className="card-wrap pl-[100px] mb-24" id="card4" aria-labelledby="card4-title">
<h2 id="card4-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
중수 재이용
</h2>
<section aria-labelledby="card4-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card4-desc" className="font-NotoSansKR leading-snug text-[#9771B0] text-5xl font-bold mb-6">요모조모 쓸모 많은 중수,<br />알뜰하게 재이용할 수 있어요!</h1>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">한번 사용한 물을 중수도 시설을 통해 재생하여<br />개별적 또는 지역적으로 재이용할 수 있습니다.</p>
<Image
src="/img/wrAbout/img_04_1.png"
alt="욕실, 세면대, 주방, 세탁기 등에서 발생한 생활용수를 중수도 시설에서 처리하여 화장실 변기용수와 조경용수로 재이용하는 시스템 흐름도입니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-6"
/>
<Image
src="/img/wrAbout/img_04_2.png"
alt="중수재이용"
width={200}
height={100}
className="mb-5 w-[20%]"
/>
<p className="font-NotoSansKR text-2xl leading-relaxed">
- 의무 설치대상: 일정규모 이상의 대규모점포, 숙박/목욕장, 공장, 각종 개발사업 등<br />
- 의무 재이용량: 물 사용량의 10% 이상<br />
- 활 용 용 도: 청소·화장실용수, 세척·살수용수, 조경용수, 공업용수 등
</p>
</section>
</article>
{/* 카드5 */}
<article ref={(el) => { cardRefs.current[4] = el; }} className="card-wrap pl-[100px] mb-24" id="card5" aria-labelledby="card5-title">
<h2 id="card5-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
하수 재이용
</h2>
<section aria-labelledby="card5-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card5-desc" className="font-NotoSansKR leading-snug text-[#00818A] text-5xl font-bold mb-6">아직 할 일이 많은 하수처리수,<br />풍족하게 재이용할 수 있어요!</h1>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">사용하고 버려져 공공하수처리장으로 모여든 물은<br />용도에 맞는 처리 후 재이용 할 수 있습니다.</p>
<Image
src="/img/wrAbout/img_05_1.png"
alt="주택과 건물에서 발생한 하수가 공공하수처리장을 거쳐 처리된 후, 물 재이용 시설에서 정화되어 공업용수, 하천유지용수, 농업용수, 세척·살수용수로 재활용됩니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-6"
/>
<Image
src="/img/wrAbout/img_05_2.png"
alt="하수처리 재이용"
width={200}
height={100}
className="mb-5 w-[20%]"
/>
<p className="font-NotoSansKR text-2xl leading-relaxed">
- 의무 이용대상: 공공하수처리장(시설용량 5,000㎥/일 이상)<br />
- 의무 재이용량: 일 하수처리량의 10% 이상<br />
- 활 용 용 도: 공업용수, 하천유지용수, 농업용수 등
</p>
</section>
</article>
{/* 카드6 */}
<article ref={(el) => { cardRefs.current[5] = el; }} className="card-wrap pl-[100px] mb-24" id="card6" aria-labelledby="card6-title">
<h2 id="card6-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 목표
</h2>
<section aria-labelledby="card6-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card6-desc" className="font-NotoSansKR leading-snug text-[#269DDA] text-5xl font-bold mb-6">2030년 물 재이용 목표를 위해<br />모두의 관심과 노력이 필요합니다.</h1>
<p className="font-NotoSansKR text-2xl mb-6 leading-relaxed">
환경부는 2030년까지의 제2차 물 재이용 기본계획을 수립하여<br />한국환경공단과 함께 물 재이용 확대 및 촉진을 위해 노력하고 있습니다.
</p>
<Image
src="/img/wrAbout/img_06_1.png"
alt="지속가능한 물 재이용 정착으로 건전한 물순환 확산"
width={800}
height={400}
className="w-[80%] mx-auto mb-6"
/>
<div className="flex gap-3 flex-wrap justify-between bg-gray-100 p-6 rounded-lg mb-8">
<div className="flex-1 text-center">
<Image
src="/img/wrAbout/icon_01.png"
alt=""
width={70}
height={70}
className="mx-auto mb-4 w-[auto] h-[70px]"
/>
<h3 className="font-NotoSansKR text-center font-medium text-nowrap mb-2 text-2xl">도시 물 순환이용<br />체계로의 전환</h3>
<ul className="font-NotoSansKR text-xl text-gray-600 leading-relaxed space-y-1">
<li className="text-nowrap">- 지속가능한 물 순환이용체계 구축</li>
<li>- 물 재이용시설 관리체계 강화</li>
</ul>
</div>
<div className="flex-1 text-center">
<Image
src="/img/wrAbout/icon_02.png"
alt=""
width={70}
height={70}
className="mx-auto mb-4 w-[auto] h-[70px]"
/>
<h3 className="font-NotoSansKR text-center font-medium text-nowrap mb-2 text-2xl">유역기반 하수처리수 재이용<br />수요확대 및 공급체계 구축</h3>
<ul className="font-NotoSansKR text-xl text-gray-600 leading-relaxed space-y-1">
<li>- 유역기반 통합물관리 연계 강화</li>
<li>- 하수처리수 재이용 공업용수 공급 활성화</li>
<li>- 하수처리수 재이용을 활용한 물순환 촉진</li>
</ul>
</div>
<div className="flex-1 text-center">
<Image
src="/img/wrAbout/icon_03.png"
alt=""
width={70}
height={70}
className="mx-auto mb-4 w-[auto] h-[70px]"
/>
<h3 className="font-NotoSansKR text-center font-medium text-nowrap mb-2 text-2xl">물 재이용 인식제고<br />및 산업발전 기반 마련</h3>
<ul className="font-NotoSansKR text-xl text-gray-600 leading-relaxed space-y-1">
<li>- 물 재이용 교육 및 대국민 홍보 강화</li>
<li>- 핵심기술 육성 및 산업발전 지원 강화</li>
</ul>
</div>
</div>
<Image
src="/img/wrAbout/img_06_2.png"
alt="물 재이용량은 2014년 13.3억톤에서 2030년 25.9억톤까지 증가할 것으로 예상됩니다. 그에 따른 기대 효과로는 연간 상수대체 17.9억톤, 물산업 시장성장 약 2조3,541억 원, 건천화된 73개 하천 해소, 약 20,550명의 고용 효과가 포함됩니다."
width={800}
height={400}
className="w-[90%] mx-auto"
/>
</section>
</article>
</main>
</div>
);
}
@@ -1,364 +0,0 @@
import Image from "next/image";
export default function WrExamplePage() {
return (
<div className="bg-[#F2F2F2]">
<div className="relative w-[950px] mx-auto pt-[50px] font-sans">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 rounded-full"></span>
물 재이용사례
</h2>
</div>
<div className="bg-[#F2F2F2] text-gray-800">
<div className="relative w-[950px] mx-auto pt-[50px] font-sans">
{/* 카드 1 */}
<div className="card-wrap mb-24" id="card1">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 1
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
경기권역 반도체 사업장 물 재이용 사업<br />
: 삼성전자 반도체 사업장 하수재이용수 공급(재이용시설 설치, 공급망 구축 등)
</p>
</section>
{/* 사업 배경 및 목적 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 배경 및 목적</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
삼성전자는 환경경영전략 실현과 반도체 공장의 안정적 용수 공급을 위해 하수 재이용수를 공업용수로 도입하기로 결정
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
1단계 사업[화성·오산(12만㎥/일) → 삼성 화성·기흥(29년 공급)]<br />
2단계 사업[수원(21만㎥/일) → 삼성 평택(30년 공급)]
</p>
</section>
{/* 특이사항 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">특이사항</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
본 사업은 광역 하수 재이용 공급망 구축의 첫 사업으로 초기 민관협의체를 구성·운영 후 관계기관(환경부, 경기도, 지자체, 공단, 수공, 삼성전자) 간 MOU 체결을 통해 사업을 추진하였으며, 반도체 사업장에 하수 재이용수를 공업용수로 공급하는 국내 첫 사업으로 위협부담 해소를 위해 사업을 단계별로 시행
</p>
</section>
{/* 단계별 시각 정보 */}
<section className="bg-white px-4 mt-8">
<Image
src="/img/wrExample/img_ex_01_1.png"
alt="사업단계 설명"
width={800}
height={400}
className="w-[100%] mx-auto mb-8"
/>
</section>
</div>
</div>
{/* 카드2 */}
<div className="card-wrap mb-24" id="card2">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 2
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
경기도 파주시 하수재이용<br />
: 운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에 공업용수 공급<br /><br />
*LG디스플레이(P10공장), 희성전자, 엔비텍 등
</p>
</section>
{/* 사업 방식 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 방식</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
처리공정 : 전처리분리막(U/F) + 역삼투서비(R/O)<br />
공급량 : 4만m³/일<br />
관로 : 13.6km<br />
사업비 : 651억원
</p>
</section>
{/* 특이사항 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">특이사항</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례
</p>
<div className="grid grid-cols-1 gap-0 items-start">
{/* 좌측 이미지 */}
<div>
<Image
src="/img/wrExample/img_ex_02_1.png"
alt="적용 성공사례 지도"
width={1000}
height={1200}
className="w-[100%] mx-auto"
/>
</div>
{/* 우측 표 */}
<div className="space-y-3">
{/* 하수처리수 공급 */}
<div>
<table className="min-w-full h-[500px]text-center border border-black">
<colgroup>
<col style={{ width: "20%" }} />
<col style={{ width: "40%" }} />
<col style={{ width: "40%" }} />
</colgroup>
<tbody>
<tr>
<td colSpan={3} className="font-NotoSansKR bg-[#ffc000] h-[30px] text-xl text-white font-semibold text-center pt-[6px] pb-[6px] px-0">하수처리수 공급</td>
</tr>
<tr className="text-xl h-[30px]">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">위치</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">금촌하수</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">운정하수</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">시설용량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">44,000㎥/일</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">60,000㎥/일</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">유입량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">27,000㎥/일<br />자연유하</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">30,000㎥/일<br />이송펌프장</td>
</tr>
</tbody>
</table>
{/* 첫 화살표 (자연유하 / 관로이송) */}
<div className="flex h-[30px] justify-between items-center my-3 px-4">
<div className="flex text-xl items-center gap-1 text-orange-600 font-bold pl-6">
<span className="font-NotoSansKR">자연유하: 161m</span>
<Image
src="/img/wrExample/img_arrow_down_1.png"
alt="오른쪽 화살표"
width={24}
height={24}
className="h-6 w-6"
/>
</div>
<div className="flex text-xl items-center gap-1 text-orange-600 font-bold">
<Image
src="/img/wrExample/img_arrow_down_1.png"
alt="왼쪽 화살표"
width={24}
height={24}
className="h-6 w-6"
/>
<span className="font-NotoSansKR">관로이송: 3.2km</span>
</div>
</div>
</div>
{/* 물재이용 처리시설 */}
<div>
<table className="min-w-full text-center border border-black">
<colgroup>
<col style={{ width: "50%" }} />
<col style={{ width: "50%" }} />
</colgroup>
<tbody>
<tr>
<td colSpan={2} className="font-NotoSansKR bg-[#00b050] text-xl h-[30px] text-white font-semibold text-center pt-[6px] pb-[6px] px-0">물재이용 처리시설</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">유입유량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">57,000㎥/일</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">재이용시설</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">40,000㎥/일</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">농축폐수처리</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">17,000㎥/일</td>
</tr>
</tbody>
</table>
{/* 두 번째 화살표 (공급관로) */}
<div className="flex flex-col items-center my-3">
<div className="flex h-[30px] items-center gap-1 text-xl text-blue-600 font-semibold">
<span className="font-NotoSansKR">공급관로 L=10.4km</span>
<Image
src="/img/wrExample/img_arrow_down_2.png"
alt="아래 화살표"
width={24}
height={24}
className="h-6 w-6"
/>
</div>
</div>
</div>
{/* 수요처 공급 */}
<div>
<table className="min-w-full text-center border border-black">
<colgroup>
<col style={{ width: "50%" }} />
<col style={{ width: "50%" }} />
</colgroup>
<tbody>
<tr>
<td colSpan={2} className="font-NotoSansKR bg-[#3399ff] h-[30px] text-xl text-white font-semibold text-center pt-[6px] pb-[6px] px-0">수요처 공급</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">수요처</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">LG디스플레이 외 2개사</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">수요량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">40,000㎥/일</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</div>
{/* 카드3 */}
<div className="card-wrap mb-24" id="card3">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 3
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
충남 보령 하수재이용<br />
: 보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진
</p>
</section>
{/* 사업 방식 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 방식</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
2017년 한국환경공단과 사업대행 위 · 수탁 협약 체결
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
처리공정 : 전처리분리막(U/F) + 역삼투서비(R/O)<br />
공급량 : 1만m3/일<br />
관로 : 8.7km<br />
사업비 : 315억원(국비 216억원, 재원협의(안))
</p>
</section>
{/* 특이사항 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">특이사항</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU (환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕
으로 추진 되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모
</p>
<div className="p-2">
<Image
src="/img/wrExample/img_ex_03_1.png"
alt="국가전력 기반시설 설명"
width={700}
height={350}
className="w-[100%] mx-auto"
/>
</div>
</section>
</div>
</div>
{/* 카드4 */}
<div className="card-wrap mb-24" id="card4">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 4
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
경기도 수원시 빗물이용과 중수도
</p>
</section>
{/* 사업 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로 비산먼지 저감<br />
&nbsp;&nbsp;※ 시설 가동중 도로 노면 온도의 7.2 ~ 26.3℃ 감소 효과 확인(21.7월, 수원시청).<br />
&nbsp;&nbsp;다만, 가뭄기나 강우량이 적을 경우 빗물을 원활히 공급하기 다소 곤란<br /><br />
- (중수도) 수원시청, 장안구청, 월드컵경기장에 빗물 연계 중수도를 화장실·조경용수 등으로 사용하여 수도요금 절감(연 7백~30백만원)
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 items-start p-2">
<div>
<Image
src="/img/wrExample/img_ex_04_1.png"
alt="월드컵경기장 인근도로 노면 살수 이미지"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><월드컵경기장 인근도로 노면 살수></span>
</div>
<div>
<Image
src="/img/wrExample/img_ex_04_2.png"
alt="주유기 모양의 빗물 공급기 이미지"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><주유기 모양의 빗물 공급기></span>
</div>
<div>
<Image
src="/img/wrExample/img_ex_04_3.png"
alt="빗물주유기에서 살수용수 공급 이미지"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><빗물주유기에서 살수용수 공급></span>
</div>
<div>
<Image
src="/img/wrExample/img_ex_04_4.png"
alt="빗물 공급기 및 빗물분사장치 위치"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><빗물 공급기 및 빗물분사장치 위치></span>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,113 +0,0 @@
import Image from "next/image";
export default function WrOverviewPage() {
return (
<div className="bg-[#F2F2F2]">
<div className="relative w-[950px] mx-auto pt-[50px] font-sans">
{/* 카드 1 - 물재이용 사업 추진 절차 */}
<div className="card-wrap mb-24" id="card1">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 사업 추진 절차
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] py-[50px]">
<table className="table-fixed w-full text-center text-sm">
<caption className="sr-only">물 재이용 추진 절차 상세 표</caption>
<colgroup>
<col style={{ width: "28%" }} />
<col style={{ width: "2%" }} />
<col style={{ width: "35%" }} />
<col style={{ width: "5%" }} />
<col style={{ width: "30%" }} />
</colgroup>
<tbody>
{/* 1단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">타당성 조사</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 2단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">매칭시스템 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">사업계획(안) 수립</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 3단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">하수도정보시스템 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">국고지원 신청</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 4단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">국고지원 확정 통보</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 5단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">사업계획 수립(실시설계)</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 6단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">(행정관) 기술검토 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">재원협의(지방환경관서장)</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 7단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">(행정관) 기술검토 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">설치승인(시도지사)</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 8단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">공사시행</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 9단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">(행정관) 기술검토 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">준공(국고정산)</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
</tbody>
</table>
</div>
</div>
{/* 카드 2 - 물재이용 매칭 시스템 */}
<div className="mt-36 card-wrap mb-24" id="card2">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 매칭 시스템이란?
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 개요 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">개요</h2>
<p className="font-NotoSansKR text-2xl font-bold text-gray-800 leading-relaxed">
기후변화 및 지자체의 개발속도 차이에 따라 물 자원의 수요-공급 불균형이 발생하여 빗물, 중수도, 하수처리 재처리수 등의 공급 여유가 있는 지자체의 재이용수를 부족한 지자체 수요처에 공급하여 불균형 해소를 통한 수자원 절약 및 물 자원 순환
</p>
</section>
{/* 구성 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">구성</h2>
<ul className=" font-NotoSansKR list-[square] pl-6 text-2xl font-bold text-gray-800 leading-relaxed space-y-2">
<li>공급자: 하수처리장, 빗물이용시설, 중수도 등</li>
<li>수요자: 산업체(냉각수), 공공시설(조경, 도로청소), 농민(농업용수) 등</li>
<li>시스템: 수질, 수량, 거리 기반 매칭, 중개</li>
<li>운영주체: 한국환경공단</li>
</ul>
</section>
{/* 절차 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-bold text-gray-800 leading-relaxed">
① 공급자 등록 → ② 수요자 조건 입력 → ③ 매칭 검토 → ④ 시스템 매칭 → ⑤ 계약·공급 이행
</p>
</section>
{/* 기대효과 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">기대효과</h2>
<ul className="font-NotoSansKR list-[square] pl-6 text-2xl font-bold text-gray-800 leading-relaxed space-y-2">
<li>물 자원 수요-공급 조정을 통한 효율적 분배</li>
<li>물 재이용시설 데이터 수집으로 체계적 관리</li>
<li>지자체, 사업시행자 등 재이용 정보 획득 용이</li>
</ul>
<div className="text-center my-6">
<Image
src="/img/wrOverview/img_ov_01_1.png"
alt="하수 재이용 시스템 흐름도. 저장탱크에서 재처리된 물이 손 씻기, 수도, 세정 등에 공급되며 재이용 활성화를 통해 최적의 물공급 방안이 마련되는 과정을 시각화함."
width={650}
height={500}
className="h-[500px] mx-auto mb-4 rounded"
/>
<p className="font-NotoSansKR text-2xl font-bold text-gray-800 mt-10">(하수 재이용 시스템 개요도)</p>
</div>
</section>
</div>
</div>
</div>
</div>
);
}
@@ -1,387 +0,0 @@
'use client';
import { regionData, regionIdMap } from '@/app/common/comFunction';
import { useDmStore } from '@/app/store';
import type { DmStatusDataType } from '@/app/store/wrStatus/type';
import { CHART_FORM, MainChart, type ChartConfig } from '@common/business_components';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { setCookie } from "../../../../src/shared/lib";
setCookie("site-id-key", process.env.NEXT_PUBLIC_SITE_ID || "DM");
interface WrStatusChartProps {
chartForm: string;
chartConfig: ChartConfig;
chartData: any;
chartTitle: string;
chartClassName?: string;
graphSize?: number;
yFieldText? : string | undefined;
yFieldText2? : string | undefined;
showLegend? : boolean;
setXAxisYearTick ?: boolean;
}
export default function WrStatusPage() {
const { Common, dmStatus, getDmStatusData,initializeCommonData} = useDmStore((state) => state);
const { year:defaultYear, statusData,facilityTotals ,chartData} = dmStatus;
const [selectedRegion, setSelectedRegion] = useState<string | null>(null);
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null);
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
const [chartOption, setChartOption] = useState<WrStatusChartProps>({} as WrStatusChartProps);
const svgContainerRef = useRef<HTMLDivElement>(null);
const colorLegend = [
{ label: '하수', color: '#FF6B6B', key: 'sewage' },
{ label: '빗물', color: '#4ECDC4', key: 'rainwater' },
{ label: '중수도', color: '#45B7D1', key: 'reclaimed' },
];
const facilityTotalsColor = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FED16A'];
// const [defaultYear, setDefaultYear] = useState<string>("");
// const defaultYear= '2023';
const handleMarkerClick = (regionId: string) => {
if (selectedRegion === regionId) {
setSelectedRegion(null);
} else {
setSelectedRegion(regionId);
}
};
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
const createDonutMarker = useCallback((svg: SVGSVGElement, x: number, y: number, name: string, values: number[]) => {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FED16A'];
const radius = 26;
const innerRadius = 18;
// const regionId = regionIdMap[name];
const regionId = regionIdMap[name];
if (!regionId) {
console.warn(`Invalid region name: ${name}`);
return;
}
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('transform', `translate(${x}, ${y})`);
g.setAttribute('id', `marker-${name}`);
g.style.cursor = 'pointer';
g.setAttribute('role', 'button');
g.setAttribute('tabindex', '0');
g.setAttribute('aria-label', `${name} 지역 선택`);
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bg.setAttribute('r', radius.toString());
bg.setAttribute('fill', 'white');
bg.setAttribute('pointer-events', 'all');
g.appendChild(bg);
let startAngle = 0;
values.forEach((value, idx) => {
const endAngle = startAngle + value * 2 * Math.PI;
const x1 = radius * Math.sin(startAngle);
const y1 = -radius * Math.cos(startAngle);
const x2 = radius * Math.sin(endAngle);
const y2 = -radius * Math.cos(endAngle);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
const d = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L 0 0 Z`;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
path.setAttribute('fill', colors[idx % colors.length] as string);
g.appendChild(path);
startAngle = endAngle;
});
const center = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
center.setAttribute('r', innerRadius.toString());
center.setAttribute('fill', 'white');
g.appendChild(center);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', '0');
text.setAttribute('y', '2');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('font-size', '13');
text.setAttribute('font-weight', 'bold');
text.setAttribute('dominant-baseline', 'middle');
text.setAttribute('fill', 'black');
text.textContent = name || '';
g.appendChild(text);
g.addEventListener('mouseenter', (e) => {
setHoveredRegion(regionId);
setTooltipPosition({ x: e.clientX, y: e.clientY });
});
g.addEventListener('mouseleave', () => {
setHoveredRegion(null);
setTooltipPosition(null);
});
g.addEventListener('click', () => {
handleMarkerClick(regionId);
});
g.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const matrix = g.getScreenCTM();
if (!matrix) return; // 또는 console.warn('matrix is null'); return;
const point = svg.createSVGPoint();
point.x = 0;
point.y = 0;
const transformed = point.matrixTransform(matrix);
setTooltipPosition({ x: transformed.x, y: transformed.y });
setHoveredRegion(regionId);
handleMarkerClick(regionId);
}
});
svg.appendChild(g);
}, [handleMarkerClick, regionIdMap]);
useEffect(() => {
if(Common.selectSidoData&&Common.selectSidoData.length>1) {
getDmStatusData(Common.selectSidoData);
}
},[Common.selectSidoData, getDmStatusData])
useEffect(() => {
const loadSVG = async () => {
try {
const response = await fetch('/img/wrStatus/southKoreaMap.svg');
const svgText = await response.text();
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = svgText;
const svg = svgContainerRef.current.querySelector('svg') as SVGSVGElement;
if (svg) {
svg.style.width = '58%';
svg.style.height = 'auto';
svg.style.objectFit = 'contain';
// Set default fills
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
if (!path.getAttribute('fill') || path.getAttribute('fill') === 'none') {
path.setAttribute('fill', '#ffffff');
}
});
// Create markers
regionData.forEach(region => {
createDonutMarker(svg, region.x, region.y, region.name, region.values);
});
}
}
} catch (error) {
console.error('SVG 로딩 실패:', error);
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = "<p className='text-red-500'>SVG 로딩 실패</p>";
}
}
};
loadSVG();
}, [createDonutMarker, regionData]);
useEffect(() => {
const svg = svgContainerRef.current?.querySelector('svg');
if (svg) {
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
const pathId = path.getAttribute('id');
if (pathId) {
if (selectedRegion === pathId) {
path.setAttribute('fill', '#2563eb');
} else {
const original = '#ffffff';
path.setAttribute('fill', original);
}
}
});
}
}, [selectedRegion]);
useEffect(() => {
if(chartData.length>0){
const chartConfig = chartData.reduce((config, element) => {
config[element.label] = {
label: element.label,
color : "hsl(var(--chart-1))"
};
return config;
}, {} as Record<string, { label: string; color: string; }>) satisfies ChartConfig;
const Option = {
chartForm : CHART_FORM.BAR,
chartConfig : chartConfig,
chartData : chartData,
chartTitle : '',
chartClassName : "w-full h-[350px] ",
graphSize : 10,
yFieldText : '시설(개소)',
showLegend : true
}
setChartOption(Option)
}else{
setChartOption({} as WrStatusChartProps)
}
}, [chartData]);
return (
<div className="flex h-screen bg-[#F2F2F2] text-gray-800">
{/* 우측 콘텐츠 영역 */}
<main className="flex-1 p-6 overflow-auto">
{/* 상단 제목 */}
<div className="mb-6 flex justify-between items-center">
<h2 className="text-2xl font-bold">물재이용 현황</h2>
</div>
{/* 콘텐츠 본문 */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{/* 지도 카드 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden max-h-[900px]">
{/* 타이틀 영역 */}
<div className="border-b border-[#DBE2E6] px-4 py-3">
<h3 className="text-[12px] font-semibold">{`재이용시설현황(${defaultYear})`}</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설현황(개소)</p>
</div>
{/* 본문 콘텐츠 */}
<div className="p-4">
<div className="bg-[#E2EDF3] p-4 rounded shadow relative">
<div className="flex justify-center">
<div
ref={svgContainerRef}
className="w-full max-w-5xl max-h-[680px] overflow-y-auto block mx-auto"
aria-label="대한민국 지역별 재이용시설 분포도"
role="region"
tabIndex={0}
/>
</div>
{typeof window !== 'undefined' && hoveredRegion && tooltipPosition && (
<div
className="fixed bg-white text-[12px] text-gray-700 border border-gray-300 shadow-lg rounded px-3 py-2 z-50"
style={{
top: tooltipPosition.y + 10,
left: tooltipPosition.x + 10,
pointerEvents: 'none',
}}
>
<div className="font-semibold mb-1">{hoveredRegion}</div>
<div className="space-y-1">
{colorLegend.map(({ label, color, key }) => (
<div key={key+'' +color} className="flex items-center space-x-2">
<span className="text-[12px] inline-block w-3 h-3 rounded-sm" style={{ backgroundColor: color }}></span>
<span className="text-[12px]">
{label}:{' '}
{statusData?.find(d => d.region === hoveredRegion)?.[key as keyof DmStatusDataType] || '-'}
</span>
</div>
))}
</div>
</div>
)}
{/* 범례 */}
<div className="absolute bottom-2 right-2 bg-white rounded-lg shadow-md p-4 text-sm space-y-2">
{facilityTotals?.map((item, index) => (
<div key ={'fcltTotal' +index}className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor:facilityTotalsColor[index]}}></span>
<span className="text-[12px] text-gray-900">{item}</span>
</div>
)) }
</div>
</div>
</div>
</div>
{/* 우측 콘텐츠 (표 + 차트) */}
<div className="col-span-1 flex flex-col space-y-4">
{/* 재이용시설현황 표 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden">
{/* 타이틀 + 버튼 영역 */}
<div className="flex justify-between items-center border-b border-[#436d85] px-4 py-3">
<div>
<h3 className="text-[12px] font-semibold">{`재이용시설현황(${defaultYear})`}</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설현황[개소(천m³/년)]</p>
</div>
</div>
<div className="p-4">
<div className="rounded border border-[#DBE2E6] shadow overflow-auto max-h-[292px] p-4">
<table className="min-w-full text-sm text-center">
<caption className="sr-only text-[12px]">행정구역별 하수, 빗물, 중수도, 온배수 재이용시설 수</caption>
<thead className="bg-gray-50 text-xs text-gray-600 uppercase border-b">
<tr>
<th className="text-[12px] font-semibold px-4 py-2">번호</th>
<th className="text-[12px] font-semibold px-4 py-2">시도</th>
<th className="text-[12px] font-semibold px-4 py-2">하수재이용시설</th>
<th className="text-[12px] font-semibold px-4 py-2">빗물이용시설</th>
<th className="text-[12px] font-semibold px-4 py-2">중수도시설</th>
{/* <th className="text-[12px] font-semibold px-4 py-2">하수(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">빗물(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">중수도(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">온배수(이용량,천𝓂³/년)</th> */}
</tr>
</thead>
<tbody className="divide-y">
{statusData?.map((row, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="text-[12px] px-4 py-2 text-center">
{index+1}
</td>
<td className="text-[12px]">{row.region}</td>
<td className="text-[12px]">{row.sewage}</td>
<td className="text-[12px]">{row.rainwater}</td>
<td className="text-[12px]">{row.reclaimed}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* 재이용통계 차트 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden">
<div className="border-b border-[#DBE2E6] px-4 py-3">
<h3 className="text-[12px] font-semibold">{`재이용통계(${defaultYear})`}</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설 현황 개소수 그래프</p>
</div>
<div className="p-4 bg-[#f9fbfd]">
<div className="h-[305px] text-gray-500">
{chartOption?
<MainChart
chartForm={chartOption.chartForm}
chartConfig={chartOption.chartConfig}
chartData={chartOption.chartData?.filter(item=>item.label!= '')}
chartTitle={chartOption.chartTitle}
chartClassName={chartOption.chartClassName}
graphSize={chartOption.graphSize}
yFieldText={chartOption.yFieldText}
showLegend={chartOption.showLegend}
/>
:<></>}
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
@@ -71,16 +71,18 @@ export default function SimilarCaseModal({
onClose,
rows,
detailsByCaseId,
onSearch
}: {
open: boolean;
onClose: () => void;
rows: SimilarCaseRow[];
detailsByCaseId: Record<string, SimilarCaseDetail | undefined>;
onSearch :(criterion: Criterion) => void;
}) {
const titleId = useId();
const [mounted, setMounted] = useState(false);
const [criterion, setCriterion] = useState<Criterion>("");
const [criterion, setCriterion] = useState<Criterion>("supplyAmt");
const [selectedCaseId, setSelectedCaseId] = useState<string>("");
useEffect(() => setMounted(true), []);
@@ -88,7 +90,8 @@ export default function SimilarCaseModal({
// 모달 열릴 때 초기화
useEffect(() => {
if (!open) return;
setCriterion("");
setCriterion("supplyAmt");
onSearch("supplyAmt");
setSelectedCaseId("");
}, [open]);
@@ -192,12 +195,14 @@ export default function SimilarCaseModal({
const v = e.target.value as Criterion;
setCriterion(v);
setSelectedCaseId("");
if(v){
onSearch(v);
}
}}
className="h-10 w-[180px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option value="">유사기준선택</option>
<option value="supplyAmt">공급량</option>
<option value="treatment">처리공법</option>
<option value="usePurpose">이용용도</option>
</select>
</div>
@@ -633,6 +633,9 @@ export default function DemandRegisterPage() {
onClose={() => setOpenSimilar(false)}
rows={similarRows}
detailsByCaseId={similarDetailsByCaseId}
onSearch={async (criterion) => {
console.log(criterion);
}}
/>
</>
);
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
//import { useDmStore } from "@/app/store";
type WaterDetail = {
toc: string;
@@ -114,12 +115,17 @@ type PairField = { label: string; value: string };
export default function DemandStatusPage() {
const rows = useMemo(() => MOCK, []);
// ✅ rows가 빈 경우도 대비 (런타임 오류 방지)
// rows가 빈 경우도 대비 (런타임 오류 방지)
const [selectedId, setSelectedId] = useState<number | null>(rows[0]?.id ?? null);
// ✅ 상세영역 스크롤 이동용
// 상세영역 스크롤 이동용
const detailRef = useRef<HTMLDivElement | null>(null);
// update
/* const updateSdmInfoData = useDmStore(
(state) => state.updateSdmInfoData
); */
// ✅ selected 계산 안정화
const selected = useMemo(() => {
if (!rows.length) return null;
+83 -5
View File
@@ -29,10 +29,15 @@ const Map = () => {
ruseUtztnFcltCd: fclt.pbnstRuseFcltId,
}));
if (!window.opener || typeof window.opener.postMessage !== "function") {
alert("부모 창을 찾을 수 없습니다. 팝업이 'noopener'로 열렸거나, 보안 정책으로 차단되었을 수 있어요.");
return;
}
window.opener.postMessage(
{
type: "SET_SUBS_INFO",
payload: subsInfoList, // 또는 필요한 데이터
payload: subsInfoList, // 배열로 전송 (최대 3개)
},
"*"
);
@@ -52,9 +57,23 @@ const Map = () => {
//하수처리시설코드로 재이용시설 목록 조회
const reuseData = async (ruseFcltPk: any) => {
if (!ruseFcltPk) {
console.warn("[reuseData] ruseFcltPk가 없습니다.");
return;
}
try {
console.log("[reuseData] API 호출 - ruseFcltPk:", ruseFcltPk);
const response = await mapService.getPbnstRuseFclts(ruseFcltPk);
const data = response.data || [];
console.log("[reuseData] API 응답 전체:", response);
// 응답이 배열이면 바로 사용, 객체면 .data 추출
const data = Array.isArray(response)
? response
: Array.isArray(response?.data)
? response.data
: response?.data
? [response.data]
: [];
console.log("[reuseData] 파싱된 data:", data);
setSelectedFclts((prev) => {
const newItems = data.filter(
(item: any) =>
@@ -62,7 +81,7 @@ const Map = () => {
Object.keys(item).length > 0 &&
!prev.some((f) => f.pbnstRuseFcltId === item.pbnstRuseFcltId)
);
console.log(newItems);
console.log("[reuseData] 추가될 newItems:", newItems);
return [...prev, ...newItems];
});
} catch (error) {
@@ -75,11 +94,45 @@ const Map = () => {
fetchData();
}, []);
// 부모창에 초기 선택값 요청 (팝업 열릴 때 기존 선택 목록 받아오기)
useEffect(() => {
const origin = window.location.origin;
if (window.opener) {
window.opener.postMessage({ type: "REQUEST_INIT_SUBS_INFO" }, origin);
}
}, []);
// 부모창에서 초기 선택값 수신
useEffect(() => {
const origin = window.location.origin;
const handler = (event: MessageEvent) => {
if (event.origin !== origin) return;
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "INIT_SUBS_INFO") {
const list = Array.isArray(data.payload) ? data.payload : [];
setSelectedFclts(
list.map((x) => ({
ruseUtztnFcltNm: x.ruseUtztnFcltNm ?? "",
pbnstRuseFcltId: x.ruseUtztnFcltCd ?? "",
}))
);
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
// 레이어 생성 후 초기화 처리
const onCheckedLayersChange = () => {
//수요처 ID
const urlParams = new URLSearchParams(window.location.search);
const dmdInfoId = urlParams.get("dmdInfoId");
console.log("[하이라이트] dmdInfoId:", dmdInfoId);
//수요처 위치로 화면 이동
const maps = gisMaps.current;
@@ -88,22 +141,42 @@ const Map = () => {
const featureId = `${layerNm}.${dmdInfoId}`;
let foundFeature: any = null;
// 현재 로드된 레이어 목록 출력
const allLayerNames = maps
.getLayers()
.getArray()
.map((l: any) => l.get("lyrTyNm") || l.get("name") || "(unnamed)");
console.log("[하이라이트] 전체 레이어 목록:", allLayerNames);
console.log("[하이라이트] 탐색 대상 레이어:", layerNm);
console.log("[하이라이트] 탐색 대상 featureId:", featureId);
maps
.getLayers()
.getArray()
.forEach((layer: any) => {
if (layer.get("lyrTyNm") == layerNm) {
console.log("[하이라이트] 레이어 발견:", layerNm);
const source = layer.getSource?.();
if (source?.getFeatureById) {
const feature = source.getFeatureById(String(featureId).trim());
console.log("[하이라이트] feature 탐색결과:", feature);
if (feature) {
foundFeature = feature;
return;
}
}
// feature 못 찾을 때 소스 내 전체 feature ID 출력
if (source?.getFeatures) {
const allIds = source.getFeatures().map((f: any) => f.getId());
console.log("[하이라이트] 소스 내 전체 feature ID:", allIds);
}
}
}
});
if (!foundFeature) {
console.warn("[하이라이트] feature를 찾지 못했습니다. 위 레이어 목록/ID를 확인하세요.");
}
if (foundFeature) {
const geometry = foundFeature.getGeometry();
@@ -133,7 +206,7 @@ const Map = () => {
{ width: 30 }
);
} else {
alert("수요처의 좌표정보를 찾을수 없으므로, 상단 조회조건의 시설명을 입력하여 위치를 조회하시기 바랍니다.");
console.warn("수요처의 좌표정보를 찾을 수 없습니다. dmdInfoId:", dmdInfoId);
}
const formatNumber = (val: any) => {
@@ -185,6 +258,7 @@ const Map = () => {
// 검색해서 재이용시설 선택하면 해당 시설로 화면 이동 처리
useEffect(() => {
console.warn(selectDataPk);
if (!selectDataPk || Object.keys(selectDataPk).length === 0) return;
const maps = gisMaps.current;
let foundFeature: any = null;
@@ -226,8 +300,12 @@ const Map = () => {
// 재이용시설 클릭 시 선택 시설목록에 추가
const onFeatureSelect = (data: any) => {
// feature의 모든 속성 출력 (어떤 키가 있는지 확인)
const properties = data.getProperties?.() ?? {};
console.log("[onFeatureSelect] feature 속성 전체:", properties);
// 처리시설코드
const swtrmtFcltCd = data.get("swtrmt_fclt_cd");
console.log("[onFeatureSelect] swtrmt_fclt_cd:", swtrmtFcltCd);
reuseData(swtrmtFcltCd);
};
@@ -22,36 +22,32 @@ export default function SelectedFcltList({
const TableContent = (
<>
<thead className="bg-gray-100 text-gray-700 text-[13px]">
<thead className="bg-gray-100 text-gray-600 text-[11px] font-medium border-b border-cyan-800">
<tr>
<th className="px-3 py-2 text-left border-b">시설명</th>
<th className="px-3 py-2 text-right border-b">
하수처리량
<br />
(㎥/일)
<th className="px-2 py-1.5 text-left w-1/3">시설명</th>
<th className="px-2 py-1.5 text-right whitespace-nowrap">
처리량(㎥/일)
</th>
<th className="px-3 py-2 text-right border-b">
재이용량
<br />
(㎥/일)
<th className="px-2 py-1.5 text-right whitespace-nowrap">
재이용량(㎥/일)
</th>
<th className="px-3 py-2 border-b">삭제</th>
<th className="px-2 py-1.5 text-center w-10">삭제</th>
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr key={item.pbnstRuseFcltId} className="border-b last:border-none">
<td className="px-3 py-2 text-[12px]">{item.ruseUtztnFcltNm}</td>
<td className="px-3 py-2 text-right text-[12px]">
<tr key={item.pbnstRuseFcltId} className="border-b border-gray-100 last:border-none hover:bg-gray-50/50">
<td className="px-2 py-1.5 text-[11px] font-medium text-gray-800 break-keep">{item.ruseUtztnFcltNm}</td>
<td className="px-2 py-1.5 text-right text-[11px] text-gray-600">
{Number(item.splyExtraCpct || 0).toLocaleString()}
</td>
<td className="px-3 py-2 text-right text-[12px]">
<td className="px-2 py-1.5 text-right text-[11px] text-gray-600">
{Number(item.ruseCpct || 0).toLocaleString()}
</td>
<td className="px-3 py-2 text-center">
<td className="px-2 py-1.5 text-center">
<button
onClick={() => handleDelete(item.pbnstRuseFcltId)}
className="text-red-500 hover:text-red-700 text-sm font-bold"
className="text-gray-400 hover:text-red-500 text-[11px] font-bold transition-colors"
aria-label="삭제"
title="삭제"
>
@@ -64,15 +60,13 @@ export default function SelectedFcltList({
</>
);
if (data.length === 0) return null;
return (
<div className="fixed right-40 top-[8rem] w-[40rem] max-h-[130rem] overflow-auto bg-white shadow-lg rounded p-4 border border-gray-300 z-50">
<h3 className="bg-cyan-700 text-white text-xl font-bold mb-2 p-2">
<div className="fixed right-20 top-[3.5rem] w-[22rem] max-h-[80vh] overflow-hidden bg-white/95 backdrop-blur-sm shadow-[0_4px_12px_rgba(0,0,0,0.1)] rounded-lg border border-gray-200 z-50 flex flex-col">
<h3 className="bg-cyan-700 text-white text-[13px] font-semibold p-2">
선택된 시설
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm text-gray-800 border border-gray-200">
<div className="overflow-y-auto w-full">
<table className="w-full text-sm text-gray-800 border-t border-gray-200 table-fixed">
{TableContent}
</table>
</div>
@@ -6,7 +6,7 @@ import { DEV_URL_WR, DEV_URL_SN } from "@/components/ui/config";
export type LyrStngProps = {
lyr_sn: string
lyr_nm: string
upper_lyr_sn: string
upper_lyr_sn: string
drawOrdr: string
flterSql: string
gobjTy: string
@@ -29,7 +29,7 @@ export type LyrStngProps = {
export const mapService = {
//레이어 설정 조회
getLyrStngs: async () => {
const response = await fetch(`${DEV_URL_SN}/api/v1/map/lyrStngs/DM_NETWORK`, {
const response = await fetch(`${DEV_URL_SN}/map/lyrStngs/DM_NETWORK`, {
method: 'GET',
headers: {
'Accept': 'application/json',
@@ -42,7 +42,7 @@ export const mapService = {
return response.json();
},
getPbnstRuseFclts: async (ruseFcltPk:any) => {
getPbnstRuseFclts: async (ruseFcltPk: any) => {
const response = await fetch(`${DEV_URL_WR}/reuseFc/pubFc/${ruseFcltPk}`, {
method: 'GET',
headers: {
@@ -0,0 +1,895 @@
// app/(with-layout)/apply/register/page.tsx
/**
* 수요정보등록(수정) 화면
*
* 🔗 사용 API
* - 수요정보등록: POST /reuseFc/request/demandInfo
* - 수요정보조회: GET /reuseFc/request/' + dmdInfoId - getSdmInfoStatusList
*/
"use client";
import { useMemo, useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import SubPageHeader from "@/components/SubPageHeader";
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
import CandidateSiteModal, { type CandidateSite } from "@/components/CandidateSiteModal";
import { useDmStore } from "@/app/store";
import type { SdmInfoFormData } from "@/app/store/sdmMng";
import SimilarCaseModal, { type SimilarCaseDetail, type SimilarCaseRow } from "@/app/(landing)/reuse/apply/modal/SimilarCaseModal";
//import type { SubsInfo } from "@/app/store/map/type";
type PHRange = { min: string; max: string };
export default function DemandRegisterPage() {
const router = useRouter();
const today = useMemo(() => {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}, []);
const [openRef, setOpenRef] = useState(false);
const [openCand, setOpenCand] = useState(false);
const [selectedCands, setSelectedCands] = useState<CandidateSite[]>([]);
// 기본정보
const [reqDate, setReqDate] = useState<string>(today);
const [reqQty, setReqQty] = useState<string>("");
const [usePurpose, setUsePurpose] = useState<string>("");
const [hopePrice, setHopePrice] = useState<string>("");
// 요구수질
const [coliform, setColiform] = useState<string>(""); // 총대장균군수
const [noDetect, setNoDetect] = useState<boolean>(false);
const [tn, setTn] = useState<string>("");
const [residualCl, setResidualCl] = useState<string>("");
const [tp, setTp] = useState<string>("");
const [turbidity, setTurbidity] = useState<string>("");
const [ph, setPh] = useState<PHRange>({ min: "", max: "" });
const [bod, setBod] = useState<string>("");
const [chloride, setChloride] = useState<string>("");
const [odor, setOdor] = useState<string>("불쾌하지 않을 것");
const [ec, setEc] = useState<string>("");
const [color, setColor] = useState<string>("");
const [remark, setRemark] = useState<string>("");
// 공급처 후보지 선택(필수아님)
const [cand1, setCand1] = useState("");
const [cand2, setCand2] = useState("");
const [cand3, setCand3] = useState("");
//const [candidateList, setCandidateList] = useState<SubsInfo[]>([]);
const useTypesData = useDmStore((state) => state.Sdm.useTypesData);
const getUseTypesData = useDmStore((state) => state.getUseTypesData);
const [openSimilar, setOpenSimilar] = useState(false);
// 후보지 팝업 메시지 핸들러
const candsRef = useRef<{ name: string; code: string }[]>([]);
useEffect(() => {
const origin = window.location.origin;
const handleMessage = (event: MessageEvent) => {
const data = event.data;
if (!data || typeof data !== "object") return;
if (data.type === "SET_SUBS_INFO") {
// 팝업에서 배열로 전송 (최대 3개)
const list = Array.isArray(data.payload) ? data.payload : [];
setCand1(list[0]?.ruseUtztnFcltNm ?? "");
setCand2(list[1]?.ruseUtztnFcltNm ?? "");
setCand3(list[2]?.ruseUtztnFcltNm ?? "");
// 추후 REQUEST_INIT_SUBS_INFO 응답용 ref 갱신
candsRef.current = list.map((x: any) => ({
name: x.ruseUtztnFcltNm ?? "",
code: x.ruseUtztnFcltCd ?? "",
}));
}
if (data.type === "REQUEST_INIT_SUBS_INFO") {
// 팝업이 현재 선택된 후보지 초기값을 요청하면 응답
const initList = candsRef.current
.filter((x) => x.name)
.map((x) => ({
ruseUtztnFcltNm: x.name,
ruseUtztnFcltCd: x.code,
}));
const target = event.source as Window | null;
target?.postMessage(
{ type: "INIT_SUBS_INFO", payload: initList },
{ targetOrigin: origin }
);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// 유사사례
const similarCaseList = useDmStore((state) => state.Sdm.similarCaseList);
const getSimilarCaseList = useDmStore((state) => state.getSimilarCaseList);
const [farmText, setFarmText] = useState("이하");
const insertSdmInfoData = useDmStore(
(state) => state.insertSdmInfoData
);
useEffect(() => {
getUseTypesData();
}, [getUseTypesData]);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (!event.data) return;
if (event.data.type === "SET_SUBS_INFO") {
const list = event.data.payload || [];
// 최대 3개
setCand1(list[0]?.ruseUtztnFcltNm || "");
setCand2(list[1]?.ruseUtztnFcltNm || "");
setCand3(list[2]?.ruseUtztnFcltNm || "");
//setCandidateList(list);
}
};
window.addEventListener("message", handler);
return () => {
window.removeEventListener("message", handler);
};
}, []);
// option
// 'req':필수 / 'num' : 숫자 / 'num+[숫자] : 자릿수
const validation = (val, text, option: string[] = []) => {
if (option.includes('req')) {
if (val.trim() === "" || val === undefined) {
alert(`${text} 항목은 필수 입력값입니다.`);
return false;
}
} else {
if (val.trim() === "") {
return true;
}
}
const numOption = option.find((opt) => opt.startsWith("num"));
if (numOption) {
const isDot = numOption.includes("Dot");
// 3. 숫자 체크
const regex = isDot ? /^\d+(\.\d+)?$/ : /^\d+$/;
if (!regex.test(val)) {
alert(
`${text}은(는) ${isDot ? "숫자 또는 소수" : "숫자"
}만 입력 가능합니다.`
);
return false;
}
// 4. 자리수 체크
const lengthStr = numOption.replace("numDot", "").replace("num", "");
if (lengthStr) {
const length = Number(lengthStr);
if (isDot) {
// 🔥 소수 자리수 체크 (소수점 기준)
const decimalPart = val.split(".")[1];
if (decimalPart && decimalPart.length > length) {
alert(`${text}은(는) 소수점 ${length}자리까지 가능합니다.`);
return false;
}
} else {
// 정수 자리수 체크
if (val.length !== length) {
alert(`${text}은(는) ${length}자리여야 합니다.`);
return false;
}
}
}
}
return true;
}
const rows: SimilarCaseRow[] = similarCaseList.map((el, idx) => ({
caseId: String(idx),
projectName: el.ruseBizNm,
period: `${el.bizBgngDate} ~ ${el.bizEndDate}`,
supplyFacilityType: el.splyFcltKnd,
supplyName: el.splyFcltNm,
supplyCapacity: Number(el.splyFcltCpct),
demandName: el.dmdFcltNm,
demandAddress: el.dmdFcltAddr,
facilityCapacity: Number(el.ruseFcltCpct),
pipeKm: Number(el.ruseFcltPplLen),
purpose: el.ruseFcltUsg,
supplyAmt: Number(el.ruseFcltSplyCpct),
treatment: el.ruseFcltPrcsMsrc,
usePurpose: el.ruseFcltUsg,
}));
const detailsByCaseId: Record<string, SimilarCaseDetail> = {};
similarCaseList.forEach((el, idx) => {
detailsByCaseId[String(idx)] = {
coliform: el.grnteWqGrssClfg,
residualCl: el.grnteWqCombResdch,
turbidity: el.grnteWqTrbt,
bod: el.grnteWqBod,
odor: el.grnteWqSmll,
color: el.grnteWqCrmty,
tn: el.grnteWqTn,
tp: el.grnteWqTp,
ph: el.grnteWqPh,
chloride: el.grnteWqClIonQnt,
ec: el.grnteWqCndctStat,
note: el.grnteWqRmrk,
costTotal: Number(el.reqBizGrssCst),
costFacility: Number(el.reqBizReuseFcltCst),
costPipe: Number(el.reqBizPplCst),
costDesign: Number(el.reqBizDesignCst),
costSupervision: Number(el.reqBizSpvsCst),
costOther: Number(el.reqBizEtc),
fundingTotal: Number(el.prcmPlanGrssCst),
fundingNational: Number(el.prcmPlanNlep),
fundingLocal: Number(el.prcmPlanLcep),
fundingSelf: Number(el.prcmPlanBrdnCst),
fundingRate: Number(el.prcmPlanEcqrRt),
};
});
// 데이터 저장
const saveInfo = async () => {
if (!validation(reqQty, '요구수량', ['req', 'numDot'])) return false;
if (!validation(usePurpose, '사용용도', ['req'])) return false;
if (!validation(hopePrice, '희망단가', ['req', 'numDot'])) return false;
/* if(!noDetect && !validation(coliform, '총대장균군수', ['req','numDot'])) return false;
if(!validation(residualCl, ['req', '결합잔류염소', 'numDot'])) return false;
if(!validation(turbidity, '탁도', ['req', 'numDot'])) return false;
if(!validation(bod, '생물화학적산소요구량', ['req', 'numDot'])) return false;
if(!validation(odor, '냄새', ['req'])) return false; */
// 필수는 아니지만 숫자로는 들어와야함.
/* if(!validation(tn, '총질소', ['numDot'])) return false;
if(!validation(residualCl, '결합잔류염소', ['numDot'])) return false;
if(!validation(tp, '총인', ['numDot'])) return false;
if(!validation(ph.min, 'ph', ['numDot'])) return false;
if(!validation(ph.max, 'ph', ['numDot'])) return false;
if(!validation(chloride, '염화물', ['numDot'])) return false;
if(!validation(ec, '전기전도도', ['numDot'])) return false; */
const userStr = sessionStorage.getItem("user");
const user = userStr ? JSON.parse(userStr) : null;
if (user) {
// 후보지 목록 (candsRef에 코드까지 보관됨)
const splyCndtList = candsRef.current
.filter((x) => x.name)
.map((x) => ({
ruseUtztnFcltNm: x.name,
ruseUtztnFcltCd: x.code,
}));
const payload: SdmInfoFormData = {
dmndTmng: new Date(reqDate),
dmdInfoId: user.dmdInfoId,
dmndQty: Number(reqQty),
wntUntprc: Number(hopePrice),
useUsgCd: usePurpose,
// 수질
grssClfg: Number(coliform),
tn: Number(tn) || null,
combResdch: Number(residualCl) || null,
tph: Number(tp) || null,
trbt: Number(turbidity),
ph: ph.min || '' + ((ph.min || ph.max) ? '~' : '') + ph.max || '',
blgcOxygDmndQnt: Number(bod),
clIonQnt: Number(chloride) || null,
smll: odor,
cdtnv: Number(ec) || null,
crmty: Number(color) || null,
rmrk: remark,
splyCndtList, // 후보지 목록 추가
}
const success = await insertSdmInfoData(payload);
if (success) {
router.push("/apply/status");
}
}
}
const searchCandidate = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const dmdInfoId = JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdInfoId?.toString();
const url = `/map/popup?dmdInfoId=${encodeURIComponent(dmdInfoId)}`;
const width = window.screen.availWidth;
const height = window.screen.availHeight;
const left = 0;
const top = 0;
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
window.open(url, "candidatePopup", popupOptions);
};
const getCriterionCode = (criterion: string) => {
switch (criterion) {
case "supplyAmt":
return "S";
case "money":
return "M";
case "usePurpose":
return "U";
default:
return "";
}
};
const searchSimilarCase = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpenSimilar(true);
};
const initQualityForm = () => {
setColiform("");
setNoDetect(false);
setTn("");
setResidualCl("");
setTp("");
setTurbidity("");
setPh({ min: "", max: "" });
setBod("");
setChloride("");
setOdor("불쾌하지 않을 것");
setEc("");
setColor("");
setRemark("");
setFarmText("이하");
}
const fncSetOptionValue = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
initQualityForm();
switch (value) {
// 청소화장실용도
case '01':
setTp('0.2');
setTurbidity('2');
setPh({ min: '5.8', max: '8.5' });
setBod('5');
setColor('20');
break;
// 세척살수용도
case '02':
setTp('0.2');
setTurbidity('2');
setPh({ min: '5.8', max: '8.5' });
setBod('5');
break;
// 조경용도
case '03':
setTp('0.2');
setTurbidity('2');
setPh({ min: '5.8', max: '8.5' });
setBod('5');
setChloride('250');
break;
// 지하수
case '05':
break;
// 농업용도
case '06':
setTurbidity('5');
setPh({ min: '5.8', max: '8.5' });
setBod('8');
setEc('2000');
setFarmText("이하 (** 간접:2000,직접:700 )");
break;
// 하천 등 유지보수
case '07':
setColiform('1000');
setTn('10');
setTp('0.5');
setPh({ min: '5.8', max: '8.5' });
setBod('5');
break;
// 공업
case '08':
setPh({ min: '5.8', max: '8.5' });
break;
default:
console.log('잘못된 선택입니다.');
}
setUsePurpose(value);
}
return (
<>
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요정보등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요정보등록" },
]}
/>
{/* 컨텐츠 영역 */}
<div>
{/* 기본정보 테이블 */}
<div className="border border-neutral-200 bg-white">
<FormRow label="요구시기" required>
<DateInput value={reqDate} onChange={setReqDate} />
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<TextInput value={reqQty} onChange={setReqQty} placeholder="" />
</FormRow>
<Divider />
<FormRow label="사용용도" required>
<select
value={usePurpose}
onChange={(e) => fncSetOptionValue(e)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option value="">선택하세요</option>
{useTypesData.map((item) => (
<option key={item.val} value={item.val}>
{item.text}
</option>
))}
</select>
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
{/* 요구수질 섹션 타이틀 바 */}
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 inline-flex justify-center items-center gap-3 px-6 py-6 text-center">
<span className="text-lg font-semibold text-neutral-800">요구수질</span>
<button
type="button"
onClick={() => setOpenRef(true)}
className="rounded border-[2px] border-[#DE3412] px-2 py-3 text-[15px] font-medium text-[#4F4F4F] hover:bg-orange-50"
>
수질기준 참고
</button>
{/* 수질기준참고 모달 */}
<WaterQualityRefModal
open={openRef}
onClose={() => setOpenRef(false)}
imageSrc="/images/landing/water_quality_ref_1.png"
/>
</div>
</div>
{/* 요구수질 테이블 */}
<div className="border border-neutral-200 bg-white">
{/* 1행 */}
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftControl={
<div className="flex items-center gap-4">
<TextInput
value={coliform}
onChange={setColiform}
disabled={noDetect}
className={noDetect ? "bg-neutral-100 text-neutral-400" : ""}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noDetect}
onChange={(e) => {
setNoDetect(e.target.checked);
if (e.target.checked) setColiform("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
}
rightLabel="총질소(T-N)(mg/L)"
rightControl={
<InlineSuffixInput value={tn} onChange={setTn} suffix="이하" />
}
/>
<Divider />
{/* 2행 */}
<QualityRow
leftLabel="결합잔류염소(mg/L)"
/* leftRequired */
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
rightLabel="총인(T-P)(mg/L)"
rightControl={
<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />
}
/>
<Divider />
{/* 3행 */}
<QualityRow
leftLabel="탁도(NTU)"
/* leftRequired */
leftControl={
<InlineSuffixInput value={turbidity} onChange={setTurbidity} suffix="이하" />
}
rightLabel="수소이온농도(pH)"
rightControl={
<div className="flex items-center gap-2">
<TextInput
value={ph.min}
onChange={(v) => setPh((p) => ({ ...p, min: v }))}
className="w-32"
/>
<span className="text-neutral-500">~</span>
<TextInput
value={ph.max}
onChange={(v) => setPh((p) => ({ ...p, max: v }))}
className="w-32"
/>
</div>
}
/>
<Divider />
{/* 4행 */}
<QualityRow
leftLabel={
<>
생물화학적산소요구량
<br />
(BOD)(mg/L)
</>
}
/* leftRequired */
leftControl={<InlineSuffixInput value={bod} onChange={setBod} suffix="이하" />}
rightLabel="염화물(mgCL/L)"
rightControl={<InlineSuffixInput value={chloride} onChange={setChloride} suffix="이하" />}
/>
<Divider />
{/* 5행 */}
<QualityRow
leftLabel="냄새"
/* leftRequired */
leftControl={
<select
value={odor}
onChange={(e) => setOdor(e.target.value)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
}
rightLabel="전기전도도(µs/cm)"
rightControl={<InlineSuffixInput value={ec} onChange={setEc} suffix={farmText} />}
/>
<Divider />
{/* 6행 */}
<QualityRow
leftLabel="색도(도)"
/* leftRequired */
leftControl={<InlineSuffixInput value={color} onChange={setColor} suffix="이하" />}
rightLabel="비고"
rightControl={
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="h-11 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500"
/>
}
/>
<Divider />
{/* 후보지 선택 */}
<div className="grid grid-cols-12">
<LabelCell>
공급처 후보지 선택
<br />
(필수아님)
</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-4">
<TextInput value={cand1} onChange={setCand1} className="max-w-[220px]" />
<TextInput value={cand2} onChange={setCand2} className="max-w-[220px]" />
<TextInput value={cand3} onChange={setCand3} className="max-w-[220px]" />
<div className="flex items-center gap-3 xl:ml-2">
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={(e) => {
searchCandidate(e);
//splyList => 해당객체가 대민에서 선택한 후보처 리스트
//setLastSub => 해당 스테이트가 선택한 마지막 후보처를 넣어야하는 스테이트
}}>
후보지 조회
</button>
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-600"
onClick={(e) => {
// TODO: 유사사례 연결
searchSimilarCase(e);
}}
>
유사사례
</button>
</div>
</div>
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="flex justify-center py-12">
<button
type="button"
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
onClick={() => {
// TODO: 저장 처리
saveInfo();
}}
>
수요정보 등록
</button>
</div>
</div>
</main>
<SimilarCaseModal
open={openSimilar}
onClose={() => setOpenSimilar(false)}
rows={rows} // 실제 데이터
detailsByCaseId={detailsByCaseId}
onSearch={async (criterion) => {
const code = getCriterionCode(criterion);
if (!code) return;
// 값은 상황에 맞게 (예시: usePurpose)
console.log(code+' req: '+reqQty+' purpose :'+usePurpose);
await getSimilarCaseList(code, code == 'S' ? reqQty : code == 'U' ? usePurpose : '');
}}
/>
<CandidateSiteModal
open={openCand}
onClose={() => setOpenCand(false)}
initialSelected={selectedCands}
onApply={(list) => {
setSelectedCands(list);
// ✅ cand1~3 입력칸에 반영(시설명만 넣는 버전)
setCand1(list[0]?.name ?? "");
setCand2(list[1]?.name ?? "");
setCand3(list[2]?.name ?? "");
setOpenCand(false);
}}
/>
</>
);
}
/* ----------------------------- UI helpers ----------------------------- */
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function LabelCell({
children,
required,
}: {
children: React.ReactNode;
required?: boolean;
}) {
return (
<div className="col-span-12 md:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
{children}
{required ? <span className="text-red-500">*</span> : null}
</span>
</div>
);
}
function FormRow({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
<LabelCell required={required}>{label}</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">{children}</div>
</div>
);
}
function QualityRow({
leftLabel,
leftRequired,
leftControl,
rightLabel,
rightControl,
}: {
leftLabel: React.ReactNode;
leftRequired?: boolean;
leftControl: React.ReactNode;
rightLabel: React.ReactNode;
rightControl: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
{/* 좌측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
<span className="leading-6">{leftLabel}</span>
{leftRequired ? <span className="text-red-500">*</span> : null}
</span>
</div>
{/* 좌측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{leftControl}</div>
{/* 우측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="leading-6">{rightLabel}</span>
</div>
{/* 우측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{rightControl}</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
disabled,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={[
"h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none",
"focus:border-neutral-500",
disabled ? "cursor-not-allowed" : "",
className ?? "",
].join(" ")}
/>
);
}
function InlineSuffixInput({
value,
onChange,
suffix,
}: {
value: string;
onChange: (v: string) => void;
suffix: string;
}) {
return (
<div className="flex items-center gap-3">
<TextInput value={value} onChange={onChange} className="max-w-[200px]" />
<span className="text-sm text-neutral-600">{suffix}</span>
</div>
);
}
function DateInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
const openPicker = () => {
const el = ref.current;
if (!el) return;
// Chrome/Edge 등 지원
if (typeof (el as any).showPicker === "function") {
(el as any).showPicker();
return;
}
// fallback (일부 브라우저)
el.focus();
el.click();
};
return (
<div className="relative w-full max-w-[200px]">
<input
ref={ref}
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
"[&::-webkit-calendar-picker-indicator]:opacity-0",
"[&::-webkit-calendar-picker-indicator]:absolute",
"[&::-webkit-calendar-picker-indicator]:right-0",
"[&::-webkit-calendar-picker-indicator]:w-10",
"[&::-webkit-calendar-picker-indicator]:h-full",
"[&::-webkit-calendar-picker-indicator]:cursor-pointer",
].join(" ")}
/>
{/* ✅ 클릭 가능한 아이콘 버튼 */}
<button
type="button"
onClick={openPicker}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700"
aria-label="달력 열기"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
);
}
@@ -0,0 +1,727 @@
"use client";
import { useDmStore } from "@/app/store";
import type { DemandCompBizType } from "@/app/store/sdmMng";
import SubPageHeader from "@/components/SubPageHeader";
import { useEffect, useMemo, useRef, useState } from "react";
type TopField = { label: string; key: string, value: string; required?: boolean };
type PairField = { label: string; key: string, value: string };
type SessionUser = { dmdInfoId?: string };
const formatDate = (value: string | Date | "" | undefined) => {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
};
const formatValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "") return "-";
return String(value);
};
const formatBelow = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "") return "-";
if (Number(value) === -1) return "불검출";
return `${value} 이하`;
};
function StatusBadge({ v }: { v: string }) {
const cls = v === "신청" ? "text-red-600" : "text-lime-600";
return <span className={["font-medium", cls].join(" ")}>{v || "-"}</span>;
}
function TextInput({
value,
onChange,
placeholder,
disabled,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={[
"h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none",
"focus:border-neutral-500",
disabled ? "cursor-not-allowed" : "",
className ?? "",
].join(" ")}
/>
);
}
function DateInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
const openPicker = () => {
const el = ref.current;
if (!el) return;
// Chrome/Edge 등 지원
if (typeof (el as any).showPicker === "function") {
(el as any).showPicker();
return;
}
// fallback (일부 브라우저)
el.focus();
el.click();
};
return (
<div className="relative w-full max-w-[200px]">
<input
ref={ref}
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
"[&::-webkit-calendar-picker-indicator]:opacity-0",
"[&::-webkit-calendar-picker-indicator]:absolute",
"[&::-webkit-calendar-picker-indicator]:right-0",
"[&::-webkit-calendar-picker-indicator]:w-10",
"[&::-webkit-calendar-picker-indicator]:h-full",
"[&::-webkit-calendar-picker-indicator]:cursor-pointer",
].join(" ")}
/>
{/* ✅ 클릭 가능한 아이콘 버튼 */}
<button
type="button"
onClick={openPicker}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700"
aria-label="달력 열기"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
);
}
export default function DemandStatusPage() {
const { getSdmInfoStatusList, updateSdmInfoData, Sdm, Common } = useDmStore((state) => state);
const { successLogin, sdmInfoStatusList } = Sdm;
const { selectSidoData, selectSigunData } = Common;
const detailRef = useRef<HTMLDivElement | null>(null);
const [isLoginStorageInfo, setIsLoginStorageInfo] = useState(false);
const [selectedDmdInfoSn, setSelectedDmdInfoSn] = useState<number | "" | null>(null);
const [transformedSdmInfoStatusList, setTransformedSdmInfoStatusList] = useState<DemandCompBizType[]>([]);
// 수정 유무
const [ isEditMode, setIsEditMode ] = useState(false);
// 수정용 (UI에서 바뀌는 값)
const [editData, setEditData] = useState<any>(null);
// 원본 백업 (취소용)
const [originData, setOriginData] = useState<any>(null);
// 사용용도
const useTypesData = useDmStore((state) => state.Sdm.useTypesData);
const getUseTypesData = useDmStore((state) => state.getUseTypesData);
useEffect(() => {
const user = sessionStorage.getItem("user");
if (user) {
setIsLoginStorageInfo(true);
}
}, []);
useEffect(() => {
getUseTypesData();
}, [getUseTypesData]);
useEffect(() => {
if (!successLogin && !isLoginStorageInfo) return;
const user = sessionStorage.getItem("user");
if (!user) return;
try {
const userData = JSON.parse(user) as SessionUser;
const dmdInfoId = userData.dmdInfoId?.trim();
if (dmdInfoId) {
getSdmInfoStatusList(dmdInfoId);
}
} catch (error) {
console.error("Error parsing user data:", error);
}
}, [successLogin, isLoginStorageInfo, getSdmInfoStatusList]);
useEffect(() => {
const mappedList = sdmInfoStatusList.map((item) => ({
...item,
ctpvCd: selectSidoData.find((sido) => sido.val === item.ctpvCd?.trim())?.text || item.ctpvCd,
sggCd: selectSigunData.find((sigun) => sigun.val === item.sggCd?.trim())?.text || item.sggCd,
}));
setTransformedSdmInfoStatusList(mappedList);
}, [sdmInfoStatusList, selectSidoData, selectSigunData]);
useEffect(() => {
const first = transformedSdmInfoStatusList[0];
if (!first) {
setSelectedDmdInfoSn(null);
return;
}
if (selectedDmdInfoSn == null) {
setSelectedDmdInfoSn(first.dmdInfoSn);
return;
}
const exists = transformedSdmInfoStatusList.some(
(item) => item.dmdInfoSn === selectedDmdInfoSn,
);
if (!exists) {
setSelectedDmdInfoSn(first.dmdInfoSn);
}
}, [transformedSdmInfoStatusList, selectedDmdInfoSn]);
const selectedSdmInfoStatus = useMemo(() => {
if (!transformedSdmInfoStatusList.length) return null;
return (
transformedSdmInfoStatusList.find((item) => item.dmdInfoSn === selectedDmdInfoSn) ??
transformedSdmInfoStatusList[0]
);
}, [transformedSdmInfoStatusList, selectedDmdInfoSn]);
const topFields: TopField[] = useMemo(() => {
if (!selectedSdmInfoStatus) return [];
return [
{ label: "요구시기", key: "dmndTmng", value: formatDate(selectedSdmInfoStatus.dmndTmng), required: true },
{ label: "요구수량(㎥/d)", key: "dmndQty", value: formatValue(selectedSdmInfoStatus.dmndQty) },
{ label: "사용용도", key: "useUsgCd", value: formatValue(selectedSdmInfoStatus.useUsgCd) },
{ label: "희망단가(원/㎥)", key: "wntUntprc", value: formatValue(selectedSdmInfoStatus.wntUntprc) },
];
}, [selectedSdmInfoStatus]);
const waterFields: PairField[] = useMemo(() => {
if (!selectedSdmInfoStatus) return [];
return [
{ label: "총대장균군수(개/100mL)", key: "grssClfg", value: formatBelow(selectedSdmInfoStatus.grssClfg) },
{ label: "결합잔류염소(mg/L)", key:"combResdch", value: formatBelow(selectedSdmInfoStatus.combResdch) },
{ label: "탁도(NTU)", key:"trbt", value: formatBelow(selectedSdmInfoStatus.trbt) },
{ label: "생물학적산소요구량(BOD)(mg/L)", key:"blgcOxygDmndQnt", value: formatBelow(selectedSdmInfoStatus.blgcOxygDmndQnt) },
{ label: "냄새", key:"smll", value: formatValue(selectedSdmInfoStatus.smll) },
{ label: "색도(도)", key:"crmty", value: formatBelow(selectedSdmInfoStatus.crmty) },
{ label: "총질소(T-N)(mg/L)", key:"tn", value: formatBelow(selectedSdmInfoStatus.tn) },
{ label: "총인(T-P)(mg/L)", key:"tph", value: formatBelow(selectedSdmInfoStatus.tph) },
{ label: "수소이온농도(pH)", key:"ph", value: formatValue(selectedSdmInfoStatus.ph) },
{ label: "염화물(mgCL/L)", key:"clIonQnt", value: formatBelow(selectedSdmInfoStatus.clIonQnt) },
{ label: "전기전도도(μS/cm)", key:"cdtnv", value: formatBelow(selectedSdmInfoStatus.cdtnv) },
{ label: "비고", key:"rmrk", value: formatValue(selectedSdmInfoStatus.rmrk) },
];
}, [selectedSdmInfoStatus]);
const candidateNames = useMemo(() => {
const list = isEditMode
? editData?.splyCndtList ?? []
: selectedSdmInfoStatus?.splyCndtList ?? [];
return [
list[0]?.ruseUtztnFcltNm ?? "",
list[1]?.ruseUtztnFcltNm ?? "",
list[2]?.ruseUtztnFcltNm ?? "",
];
}, [isEditMode, editData, selectedSdmInfoStatus]);
const onSelectSdmInfoStatus = (dmdInfoSn: number | "") => {
if(isEditMode){
if(confirm('저장되지 않은 데이터는 사라집니다.\n진행하시겠습니까?')){
setIsEditMode(false);
}
}
setSelectedDmdInfoSn(dmdInfoSn);
requestAnimationFrame(() => {
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
};
// ph 전후로 나눔
const getRange = (val: string) => {
const a = val.split('~')[0]||''; const b = val.split('~')[1]||'';
return { a, b };
};
const searchCandidate = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const dmdInfoId = JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdInfoId?.toString();
const url = `/map/popup?dmdInfoId=${encodeURIComponent(dmdInfoId)}`;
const width = window.screen.availWidth;
const height = window.screen.availHeight;
const left = 0;
const top = 0;
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
window.open(url, "candidatePopup", popupOptions);
};
const saveInfo = () => {
if(confirm('수정된 데이터를 저장하시겠습니까?')){
const payload = {
...editData,
splyCndtList : editData.splyCndtList ?? []
};
updateSdmInfoData(payload).then((res) => {
if (res) {
const user = JSON.parse(sessionStorage.getItem("user") || "{}");
if (user?.dmdInfoId) {
getSdmInfoStatusList(user.dmdInfoId);
}
setIsEditMode(false);
}
});
}
}
useEffect(() => {
const handler = (event: MessageEvent) => {
if (!event.data) return;
if (event.data.type === "SET_SUBS_INFO") {
const list = event.data.payload || [];
setEditData((prev) => ({
...prev,
splyCndtList: list,
}));
}
};
window.addEventListener("message", handler);
return () => {
window.removeEventListener("message", handler);
};
}, []);
return (
<main className="bg-white">
<SubPageHeader
title="수요신청현황"
crumbs={[
{ label: "Home", href: "/main" },
{ label: "수요신청", href: "/apply/status" },
{ label: "수요신청현황" },
]}
hideFooterLine
hideGuideText
/>
<div>
<section className="mt-6">
<div className="text-2xl font-bold text-slate-700">수요정보 신청현황</div>
<div className="mt-3 border border-neutral-200">
<table className="w-full table-fixed">
<thead className="bg-[#E7F4E2]">
<tr className="text-sm text-slate-700">
<th className="w-[60px] px-3 py-3 text-center">NO.</th>
<th className="w-[120px] px-3 py-3 text-center">시도</th>
<th className="w-[120px] px-3 py-3 text-center">시군구</th>
<th className="w-[180px] px-3 py-3 text-center">회사명</th>
<th className="px-3 py-3 text-center">공장주소</th>
<th className="w-[140px] px-3 py-3 text-center">전화번호</th>
<th className="w-[120px] px-3 py-3 text-center">요구시기</th>
<th className="w-[120px] px-3 py-3 text-center">등록일자</th>
<th className="w-[120px] px-3 py-3 text-center">수정일자</th>
<th className="w-[110px] px-3 py-3 text-center">진행상태</th>
</tr>
</thead>
<tbody className="bg-white text-base text-slate-700">
{transformedSdmInfoStatusList.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-10 text-center text-slate-400">
조회된 신청내역이 없습니다.
</td>
</tr>
) : (
transformedSdmInfoStatusList.map((sdmInfoStatus, index) => {
const active = sdmInfoStatus.dmdInfoSn === selectedSdmInfoStatus?.dmdInfoSn;
return (
<tr
key={`${sdmInfoStatus.dmdInfoSn}-${index}`}
role="row"
tabIndex={0}
onClick={() => onSelectSdmInfoStatus(sdmInfoStatus.dmdInfoSn)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onSelectSdmInfoStatus(sdmInfoStatus.dmdInfoSn);
}
}}
className={[
"border-t border-neutral-200 outline-none",
"cursor-pointer",
active ? "bg-slate-50" : "hover:bg-slate-50",
"focus-visible:ring-2 focus-visible:ring-slate-300",
].join(" ")}
aria-selected={active}
>
<td className="px-3 py-3 text-center">{sdmInfoStatus.index || index + 1}</td>
<td className="px-3 py-3 text-center">{formatValue(sdmInfoStatus.ctpvCd)}</td>
<td className="px-3 py-3 text-center">{formatValue(sdmInfoStatus.sggCd)}</td>
<td className="px-3 py-3 text-center">{formatValue(sdmInfoStatus.dmdCoNm)}</td>
<td className="px-3 py-3 text-center">{formatValue(sdmInfoStatus.daddr)}</td>
<td className="px-3 py-3 text-center">{formatValue(sdmInfoStatus.telno)}</td>
<td className="px-3 py-3 text-center">{formatDate(sdmInfoStatus.dmndTmng)}</td>
<td className="px-3 py-3 text-center">{formatDate(sdmInfoStatus.regDt)}</td>
<td className="px-3 py-3 text-center">{formatDate(sdmInfoStatus.mdfcnDt)}</td>
<td className="px-3 py-3 text-center">
<StatusBadge v={formatValue(sdmInfoStatus.status)} />
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
<section className="mt-10 pb-16" ref={detailRef}>
<div className="flex justify-between items-center">
<div className="text-2xl font-bold text-slate-700">수요신청 상세현황</div>
{
!isEditMode ? (
<button
type="button"
className="h-8 rounded-md bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
onClick={() => {
setOriginData(selectedSdmInfoStatus);
setEditData({...selectedSdmInfoStatus});
setIsEditMode(true)}
}
>
수정
</button>
) : (
<div className="flex gap-2">
<button
type="button"
className="h-8 rounded-md bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
onClick={() => {
saveInfo();
}}
>
저장
</button>
<button
type="button"
className="h-8 rounded-md bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
onClick={() => {
setEditData(originData);
setIsEditMode(false)
}}
>
취소
</button>
</div>
)
}
</div>
{!selectedSdmInfoStatus ? (
<div className="mt-3 border border-neutral-200 px-6 py-10 text-center text-sm text-slate-400">
상세 정보를 표시할 신청건이 없습니다.
</div>
) : (
<div className="mt-3 border border-neutral-200">
<div className="bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수량
</div>
<div className="grid grid-cols-12 text-sm">
{topFields.map((field) => (
<div
key={field.label}
className="col-span-12 md:col-span-6 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-4 bg-sky-50 px-4 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{field.label}
{field.required ? <span className="ml-1 text-red-500">*</span> : null}
</div>
{
!isEditMode ?
<div className="col-span-8 px-4 py-4 flex items-center justify-center text-center">
{field.key==='useUsgCd'? useTypesData.filter(item=>item.val===field.value)[0]?.text: field.value}
</div>
: <div className="col-span-8 px-4 py-4 flex items-center justify-center text-center">
{field.key ==='dmndTmng'?
<DateInput value={editData?.[field.key] ?? ""} onChange={(v) =>
setEditData((prev) => ({
...prev,
[field.key]: v,
}))
} />
: field.key ==='useUsgCd'?
<select
value={editData?.[field.key] ?? ""}
onChange={(e) =>
setEditData((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option value="">선택하세요</option>
{useTypesData.map((item) => (
<option key={item.val} value={item.val}>
{item.text}
</option>
))}
</select>
: <TextInput value={editData?.[field.key] ?? ""} onChange={(v) =>
setEditData((prev) => ({
...prev,
[field.key]: v,
}))
}></TextInput>
}
</div>
}
</div>
))}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수질
</div>
{waterFields.map((field) => {
const { a, b } = editData?.ph ? getRange(editData.ph) : { a: "", b: "" };
return (
<div
key={field.label}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{field.label}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{
!isEditMode ? field.value
:
field.key === "grssClfg" ? (
<div className="flex items-center gap-4">
<TextInput
value={
editData?.grssClfg === -1
? ""
: editData?.grssClfg ?? ""
}
onChange={(v) =>
setEditData((prev) => ({
...prev,
grssClfg: v,
}))
}
disabled={editData?.grssClfg === -1}
className={
editData?.grssClfg === -1
? "bg-neutral-100 text-neutral-400"
: ""
}
/>
<label className="flex items-center gap-2 w-3/4 text-sm text-neutral-700">
<input
type="checkbox"
checked={editData?.grssClfg === -1}
onChange={(e) => {
const checked = e.target.checked;
setEditData((prev) => ({
...prev,
grssClfg: checked ? -1 : "",
}));
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
) : field.key === 'smll' ? (
<select
value={
editData?.smll === -1
? ""
: editData?.smll ?? ""
}
onChange={(v) =>
setEditData((prev) => ({
...prev,
grssClfg: v,
}))
}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500">
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
) : field.key === 'ph' ? (
<>
<TextInput
value={a}
onChange={(v) =>
setEditData((prev) => ({
...prev,
[field.key]: `${v}~${b}`,
}))
}
/>
~
<TextInput
value={b}
onChange={(v) =>
setEditData((prev) => ({
...prev,
[field.key]: `${a}~${v}`,
}))
}
/>
</>
) : (
<>
<TextInput
value={editData?.[field.key] ?? ""}
onChange={(v) =>
setEditData((prev) => ({
...prev,
[field.key]: v,
}))
}
/>
{
field.key==='tn'||field.key === 'tph'||field.key === 'trbt'||field.key==='blgcOxygDmndQnt'||field.key==='clIonQnt'||field.key==='cdtnv'|| field.key=='crmty'
?<>이하</>:<></>
}
</>
)
}
</div>
</div>
)})}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
공급처 후보지
{
isEditMode &&
<button
type="button"
className="h-8 w-25 ml-12 rounded-md bg-[#888] px-2 text-base text-white hover:bg-neutral-700"
onClick={(e) => {
searchCandidate(e);
//splyList => 해당객체가 대민에서 선택한 후보처 리스트
//setLastSub => 해당 스테이트가 선택한 마지막 후보처를 넣어야하는 스테이트
}}>
후보지 조회
</button>
}
</div>
{candidateNames.map((candidateName, index) => (
isEditMode ? (
<div
key={`candidate-${index}`}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
후보지{index + 1}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
<TextInput
value={candidateName}
disabled={true}
onChange={(v) => {
setEditData((prev) => {
const newList = [...(prev.splyCndtList || [])];
newList[index] = {
...newList[index],
ruseUtztnFcltNm: v,
};
return {
...prev,
splyCndtList: newList,
};
});
}}
/>
</div>
</div>
) :
<div
key={`candidate-${index}`}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
후보지{index + 1}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{candidateName}
</div>
</div>
))}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-t border-b border-neutral-200">
수요신청 진행상태
</div>
<div className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200">
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
진행상태
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{formatValue(selectedSdmInfoStatus.status)}
</div>
</div>
<div className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200">
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
공급처매칭결과
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{formatValue(selectedSdmInfoStatus.ruseUtztnFcltNm)}
</div>
</div>
</div>
</div>
)}
</section>
</div>
</main>
);
}

Some files were not shown because too many files have changed in this diff Show More