diff --git a/.gitignore b/.gitignore index c5082bba3..ee2cfd052 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,7 @@ # dependencies node_modules/ - -# build -.next/ -dist/ +.idea/ # build .next @@ -22,9 +19,14 @@ apps/fo/.next/**/* #.env* .qodo +# next-agents-md +.next-docs/ + pnpm-lock.yaml settings.json .npmrc .cursor +.agent apps/**/tsconfig.tsbuildinfo +logs/ diff --git a/.husky/pre-push b/.husky/pre-push index 5d858c3ae..2adeaead9 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,8 +1,10 @@ #!/bin/sh BRANCH=$(git rev-parse --abbrev-ref HEAD) -REMOTE_REF="origin/$BRANCH" -if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then - pnpm turbo run build --filter="...[$REMOTE_REF]" -else - pnpm turbo run build +if [ "$BRANCH" != "main" ]; then + exit 0 fi +echo "Branch: $BRANCH" +echo "Changed packages (dry-run):" +pnpm turbo run build --filter="...[origin/$BRANCH]" --dry-run +echo "Starting build..." +pnpm turbo run build --filter="...[origin/$BRANCH]" diff --git a/apps/_template/.env.example b/apps/_template/.env.example index b2d706024..f9794a6f8 100644 --- a/apps/_template/.env.example +++ b/apps/_template/.env.example @@ -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 diff --git a/apps/_template/package.json b/apps/_template/package.json index f56c904c6..9227dcf64 100644 --- a/apps/_template/package.json +++ b/apps/_template/package.json @@ -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", diff --git a/apps/cm/package.json b/apps/cm/package.json index 9d4e1da8f..bde698393 100644 --- a/apps/cm/package.json +++ b/apps/cm/package.json @@ -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", diff --git a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx index 1aa4f8e61..5366696f7 100644 --- a/apps/cm/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cm/src/entities/auth/lib/auth-wrapper.tsx @@ -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"); } } diff --git a/apps/cm/src/pages/auth/login/ui/login-page.tsx b/apps/cm/src/pages/auth/login/ui/login-page.tsx index fed7be4ad..297d28a8f 100644 --- a/apps/cm/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cm/src/pages/auth/login/ui/login-page.tsx @@ -145,11 +145,11 @@ function LoginPage() {

- 유역하수도 통합플랫폼 + 유역하수도통합플랫폼
- 통합관리시스템 + 하수도 포탈

-

유역하수도 통합플랫폼에 오신 것을 환영합니다.

+

유역하수도통합플랫폼 하수도 포탈에 오신 것을 환영합니다.

logo diff --git a/apps/common/features/next.config.ts b/apps/common/features/next.config.ts index 34965cf32..8a50ea611 100644 --- a/apps/common/features/next.config.ts +++ b/apps/common/features/next.config.ts @@ -1,6 +1,21 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", + experimental: { + proxyTimeout: 600000, + }, + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + logging: { + fetches: { + fullUrl: true, + }, + }, async rewrites() { return { afterFiles: [ diff --git a/apps/common/features/package.json b/apps/common/features/package.json index 14a96b6f8..7118ae49d 100644 --- a/apps/common/features/package.json +++ b/apps/common/features/package.json @@ -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", diff --git a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx index e518f938a..d514569e0 100644 --- a/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx @@ -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(); } })(); }, []); diff --git a/apps/cs/.gitignore b/apps/cs/.gitignore index b63718b61..f80a96304 100644 --- a/apps/cs/.gitignore +++ b/apps/cs/.gitignore @@ -38,4 +38,5 @@ yarn-error.log* next-env.d.ts .env* -!.env.example \ No newline at end of file +!.env.example +/start_cs.log diff --git a/apps/cs/package.json b/apps/cs/package.json index 03ec06fcf..27f43fcc1 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -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", diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx index c0be4c2dd..57ae75c34 100644 --- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx @@ -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); } } })(); diff --git a/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx b/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx index 7ae7990a1..d94d79c9a 100644 --- a/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx +++ b/apps/cs/src/pages/loginlog/ui/loginlog-page.tsx @@ -39,10 +39,10 @@ function LoginlogPage() { const [presetDateRange, setPresetDateRange] = useState(""); const [dateRange, setDateRange] = useState(defaultDateRange); const [userId, setUserId] = useState(""); - const [errOcrnYn, setErrOcrnYn] = useState("N"); + const [errOcrnYn, setErrOcrnYn] = useState("ALL"); const [searchParams, setSearchParams] = useState({ userId: "", - errOcrnYn: "N", + errOcrnYn: "", searchDateFrom: dayjs().subtract(7, "day").format("YYYY-MM-DD"), searchDateTo: dayjs().format("YYYY-MM-DD"), sort: "lgnDt,desc", @@ -87,7 +87,7 @@ function LoginlogPage() { setSearchParams({ ...searchParams, userId, - errOcrnYn, + errOcrnYn: errOcrnYn === "ALL" ? "" : errOcrnYn, searchDateFrom: dateRange.from ? dayjs(dateRange.from).format("YYYY-MM-DD") : "", @@ -102,12 +102,12 @@ function LoginlogPage() { const handleReset = () => { setUserId(""); - setErrOcrnYn("N"); + setErrOcrnYn("ALL"); setDateRange(defaultDateRange); setSearchParams({ ...searchParams, userId: "", - errOcrnYn: "N", + errOcrnYn: "", searchDateFrom: dayjs().subtract(7, "day").format("YYYY-MM-DD"), searchDateTo: dayjs().format("YYYY-MM-DD"), }); @@ -180,6 +180,7 @@ function LoginlogPage() { + 전체 정상 오류 diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts index c6373724f..95d1a4477 100644 --- a/apps/cs/src/shared/config/env.ts +++ b/apps/cs/src/shared/config/env.ts @@ -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 = diff --git a/apps/dc/package.json b/apps/dc/package.json index 747cdf91e..5835b9ab6 100644 --- a/apps/dc/package.json +++ b/apps/dc/package.json @@ -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", diff --git a/apps/dc/src/entities/auth/lib/auth-wrapper.tsx b/apps/dc/src/entities/auth/lib/auth-wrapper.tsx index 1f196ed52..57ae75c34 100644 --- a/apps/dc/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/dc/src/entities/auth/lib/auth-wrapper.tsx @@ -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); } } })(); diff --git a/apps/dc/src/shared/config/env.ts b/apps/dc/src/shared/config/env.ts index 89d2c039c..9edea0e2d 100644 --- a/apps/dc/src/shared/config/env.ts +++ b/apps/dc/src/shared/config/env.ts @@ -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"; diff --git a/apps/dm/app/(popup-layout)/map/popup/page.tsx b/apps/dm/app/(popup-layout)/map/popup/page.tsx index ea1e4b174..e410ad2a7 100644 --- a/apps/dm/app/(popup-layout)/map/popup/page.tsx +++ b/apps/dm/app/(popup-layout)/map/popup/page.tsx @@ -235,6 +235,7 @@ const Map = () => { <>
- 이전 @@ -480,10 +483,12 @@ export default function CaseTabsCarousel() { ].join(" ")} aria-label="다음" > - 다음 @@ -513,10 +518,13 @@ export default function CaseTabsCarousel() { className="overflow-hidden rounded-[22px] border-2 border-sky-400 bg-white" >
- {c.title}
diff --git a/apps/dm_new/app/(landing)/reuse/_components/ImageSlider.tsx b/apps/dm_new/app/(landing)/reuse/_components/ImageSlider.tsx index 6c50d5a5c..41d8987b8 100644 --- a/apps/dm_new/app/(landing)/reuse/_components/ImageSlider.tsx +++ b/apps/dm_new/app/(landing)/reuse/_components/ImageSlider.tsx @@ -1,6 +1,7 @@ // apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx "use client"; +import Image from "next/image"; import { useEffect, useMemo, useRef, useState } from "react"; type Slide = { src: string; alt?: string }; @@ -86,10 +87,12 @@ export default function CaseImageSlider({ w-[80px] h-[80px] rounded-full border border-black/20 bg-white grid place-items-center shadow-sm hover:bg-black/[0.03]" > - 이전 @@ -102,10 +105,12 @@ export default function CaseImageSlider({ w-[80px] h-[80px] rounded-full border border-black/20 bg-white grid place-items-center shadow-sm hover:bg-black/[0.03]" > - 다음 @@ -129,10 +134,13 @@ export default function CaseImageSlider({ > {safeSlides.map((s, i) => (
- {s.alt
diff --git a/apps/dm_new/app/(landing)/reuse/_components/Lnb.tsx b/apps/dm_new/app/(landing)/reuse/_components/Lnb.tsx index 4a33e7314..f5bc7bbfe 100644 --- a/apps/dm_new/app/(landing)/reuse/_components/Lnb.tsx +++ b/apps/dm_new/app/(landing)/reuse/_components/Lnb.tsx @@ -1,6 +1,7 @@ // apps/dm/app/(landing)/reuse/_components/Lnb.tsx "use client"; +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; @@ -29,7 +30,15 @@ export default function Lnb({ title, items }: { title: string; items: LnbItem[] const [openLabel, setOpenLabel] = useState(defaultOpenLabel); return ( -
@@ -62,4 +66,4 @@ export default function Cases() {
); -} \ No newline at end of file +} diff --git a/apps/dm_new/app/(landing)/reuse/_sections/Hero.tsx b/apps/dm_new/app/(landing)/reuse/_sections/Hero.tsx index 8f93bdd72..c520f5aea 100644 --- a/apps/dm_new/app/(landing)/reuse/_sections/Hero.tsx +++ b/apps/dm_new/app/(landing)/reuse/_sections/Hero.tsx @@ -1,4 +1,5 @@ // apps/dm/app/(landing)/reuse/_sections/Hero.tsx +import Image from "next/image"; import styles from "../reuse.module.css"; export default function Hero() { @@ -21,9 +22,15 @@ export default function Hero() {
- + scroll down
); -} \ No newline at end of file +} diff --git a/apps/dm_new/app/(landing)/reuse/_sections/IntroCards.tsx b/apps/dm_new/app/(landing)/reuse/_sections/IntroCards.tsx index e7153d721..c8c69ad7b 100644 --- a/apps/dm_new/app/(landing)/reuse/_sections/IntroCards.tsx +++ b/apps/dm_new/app/(landing)/reuse/_sections/IntroCards.tsx @@ -1,12 +1,17 @@ // apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx "use client"; +import Image, { type StaticImageData } from "next/image"; import Link from "next/link"; +import card01 from "@/public/images/landing/card_01.jpg"; +import card02 from "@/public/images/landing/card_02.jpg"; +import card03 from "@/public/images/landing/card_03.jpg"; +import card04 from "@/public/images/landing/card_04.jpg"; type IntroCardItem = { title: string; desc: string; - image: string; + image: StaticImageData; href: string; }; @@ -14,25 +19,25 @@ const INTRO_CARDS: IntroCardItem[] = [ { title: "물 재이용 법", desc: "물의 재이용 촉진 및 지원에 관한 법률을 시행하고, 일부 법률은 개정되었습니다.", - image: "/images/landing/card_01.jpg", + image: card01, href: "/reuse/intro/law", }, { title: "친환경 수자원 빗물", desc: "가뭄을 극복하고 홍수를 예방하기 위해 비가 내릴 때 빗물을 모아두었다가 사용합니다.", - image: "/images/landing/card_02.jpg", + image: card02, href: "/reuse/intro/rain", }, { title: "알뜰하게 재이용되는 중수", desc: "한번 사용한 물을 중수도 시설을 통해 재생하여 개별적 또는 지역적으로 재이용할 수 있습니다.", - image: "/images/landing/card_03.jpg", + image: card03, href: "/reuse/intro/greywater", }, { title: "하수처리수 재이용", desc: "사용하고 버려진 공공하수처리장으로 모여진 물을 용도에 맞는 처리 후 재이용 할 수 있습니다.", - image: "/images/landing/card_04.jpg", + image: card04, href: "/reuse/intro/treated", }, ]; @@ -61,10 +66,12 @@ export default function IntroCards() { className="group relative h-[280px] md:h-[320px] lg:h-[500px] overflow-hidden rounded-2xl cursor-pointer focus:outline-none focus:ring-2 focus:ring-sky-400/60" > {/* 배경 이미지 (이미 그라디언트 포함) */} - {card.title} {/* 텍스트 영역 */} @@ -83,4 +90,4 @@ export default function IntroCards() { ); -} \ No newline at end of file +} diff --git a/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx b/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx index aa5a9c0ee..75253392a 100644 --- a/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx +++ b/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx @@ -133,7 +133,7 @@ export default function CandidateSiteModal({ /> {/* panel */} -
+
{/* header */}

후보지 조회

diff --git a/apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx b/apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx new file mode 100644 index 000000000..f997d88e1 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx @@ -0,0 +1,539 @@ +// apps/dm/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx (유사사례 모달) +"use client"; + +import { useEffect, useMemo, useState, useId } from "react"; +import { createPortal } from "react-dom"; + +type Criterion = "" | "supplyAmt" | "treatment" | "usePurpose"; + +export type SimilarCaseRow = { + caseId: string; + projectName: string; + period: string; + + supplyFacilityType?: string; + supplyName?: string; + supplyCapacity?: number; + + demandName?: string; + demandAddress?: string; + + facilityCapacity?: number; + pipeKm?: number; + purpose?: string; + + // 유사기준용 + supplyAmt?: number; // 공급량(m3/일) + treatment?: string; // 처리공법 + usePurpose?: string; // 이용용도 +}; + +export type SimilarCaseDetail = { + // 보증수질 + coliform?: string; + residualCl?: string; + turbidity?: string; + bod?: string; + odor?: string; + color?: string; + tn?: string; + tp?: string; + ph?: string; + chloride?: string; + ec?: string; + note?: string; + + // 소요사업비(백만원) - 현재 타입에 있는 항목만 매핑 + costTotal?: number; + costFacility?: number; + costPipe?: number; + costDesign?: number; + costSupervision?: number; + costOther?: number; + + // 재원조달계획(백만원) + fundingTotal?: number; + fundingNational?: number; + fundingLocal?: number; + fundingSelf?: number; + fundingRate?: number; // % +}; + +function formatNumber(v: any) { + if (v === null || v === undefined || v === "" || Number.isNaN(Number(v))) return "-"; + const n = typeof v === "number" ? v : Number(String(v).replaceAll(",", "")); + if (!Number.isFinite(n)) return "-"; + return n.toLocaleString("ko-KR"); +} + +export default function SimilarCaseModal({ + open, + onClose, + rows, + detailsByCaseId, +}: { + open: boolean; + onClose: () => void; + rows: SimilarCaseRow[]; + detailsByCaseId: Record; +}) { + const titleId = useId(); + + const [mounted, setMounted] = useState(false); + const [criterion, setCriterion] = useState(""); + const [selectedCaseId, setSelectedCaseId] = useState(""); + + useEffect(() => setMounted(true), []); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (!open) return; + setCriterion(""); + setSelectedCaseId(""); + }, [open]); + + // ESC 닫기 + 배경 스크롤 잠금 + useEffect(() => { + if (!open) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + window.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prevOverflow; + }; + }, [open, onClose]); + + const groupInfo = useMemo(() => { + const counts = new Map(); + rows.forEach((r) => counts.set(r.caseId, (counts.get(r.caseId) ?? 0) + 1)); + + const firstIndex = new Map(); + rows.forEach((r, idx) => { + if (!firstIndex.has(r.caseId)) firstIndex.set(r.caseId, idx); + }); + + return { counts, firstIndex }; + }, [rows]); + + const selectedDetail = selectedCaseId ? detailsByCaseId[selectedCaseId] : undefined; + + // open 타입 방어 + if (typeof open !== "boolean") { + console.error("SimilarCaseModal: open prop is not boolean:", open); + return null; + } + + + if (!open || !mounted) return null; + + const tdBase = + "border border-[#C9C9C9] px-2 py-2 text-sm text-neutral-800 align-middle"; + const thBase = + "border border-[#C9C9C9] bg-[#E7F4E2] px-2 py-2 text-sm font-semibold text-neutral-800 text-center align-middle"; + + const highlightTd = "bg-neutral-200"; + + const showList = criterion !== ""; + const hasRows = rows.length > 0; + + const ui = ( +
+ {/* overlay */} + +
+
+ + {/* body */} +
+ {/* 상단 타이틀 + 유사기준 */} +
+
+ 물 재이용 사업 유사 사례 +
+ +
+ 유사기준 + +
+
+ + {/* 리스트 영역 */} +
+ {!showList ? ( +
+
유사기준을 선택해주세요.
+
+ ) : !hasRows ? ( +
+
조회 결과가 없습니다.
+
+ ) : ( + // 여기만 스크롤: rows가 4개/더 많아져서 높이를 넘으면 자동 스크롤 생성 +
+ + {/* thead sticky: bg + shadow + z-index로 겹침/투명 방지 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {rows.map((r, idx) => { + const isFirst = groupInfo.firstIndex.get(r.caseId) === idx; + const rowSpan = groupInfo.counts.get(r.caseId) ?? 1; + const selected = selectedCaseId === r.caseId; + + const supplyAmtCls = criterion === "supplyAmt" ? highlightTd : ""; + const treatmentCls = criterion === "treatment" ? highlightTd : ""; + const usePurposeCls = criterion === "usePurpose" ? highlightTd : ""; + + return ( + setSelectedCaseId(r.caseId)} + > + {isFirst ? ( + <> + + + + + ) : null} + + + + + + + + + + + + + + + + + ); + })} + +
+ 선택 + + 사업명 + + 사업기간 + + 공급처 + + 수요처 + + 재이용시설 +
시설
종류
공급처명 + 시설용량 +
+ (m³/일) +
수요처명주소 + 시설용량 +
+ (m³/일) +
+ 관로 +
+ (km) +
용도 + 공급량 +
+ (m³/일) +
처리공법이용용도
+ + + {r.projectName ?? "-"} + + {r.period ?? "-"} + {r.supplyFacilityType ?? "-"}{r.supplyName ?? "-"}{formatNumber(r.supplyCapacity)}{r.demandName ?? "-"}{r.demandAddress ?? "-"}{formatNumber(r.facilityCapacity)}{formatNumber(r.pipeKm)}{r.purpose ?? "-"} + {formatNumber(r.supplyAmt)} + + {r.treatment ?? "-"} + + {r.usePurpose ?? "-"} +
+
+ )} +
+ +
+ + {/* 상세정보 */} +
+
상세정보
+ + {/* 1) 보증수질 */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 보증수질 +
+ 총대장균군수
(개/100ml) +
+ 용도별 결합 잔류염소
(mg/L) +
+ 탁도
(NTU) +
+ 생물학적
산소요구량(BOD)(mg/L) +
냄새색도(도)총질소
(T-N)(mg/L)
총인
(T-P)(mg/L)
수소이온
농도(pH)
염화물
(mgCL/L)
전기전도도
(μs/cm)
비고
{selectedDetail?.coliform ?? "-"}{selectedDetail?.residualCl ?? "-"}{selectedDetail?.turbidity ?? "-"}{selectedDetail?.bod ?? "-"}{selectedDetail?.odor ?? "-"}{selectedDetail?.color ?? "-"}{selectedDetail?.tn ?? "-"}{selectedDetail?.tp ?? "-"}{selectedDetail?.ph ?? "-"}{selectedDetail?.chloride ?? "-"}{selectedDetail?.ec ?? "-"}{selectedDetail?.note ?? "-"}
+
+ + {/* 2) 사업비 */} +
+ + + + + + + + + + {/* 소요사업비 */} + + + + + + + + + + + + + + + {/* 재원조달 */} + + + + + + + {/* 정산 */} + + + + + + + + + + + + + + + + {/* 소요사업비 */} + + + + + + + + + + + + + + + + {/* 재원조달 */} + + + + + + + {/* 정산(필드 없으면 동일 값 표시) */} + + + + + + + +
+ 소요사업비(백만원) + + 재원조달계획(백만원) + + 사업비 정산(백만원) +
총토목건축기계전기
·계측
GIS
DB
CCTV시공 및 준공폐기물설계비감리비시설부대기타총국비지방비원인자부담국고비율총국비지방비원인자부담국고비율
재이용
시설
관로
+ {selectedDetail?.costTotal != null ? formatNumber(selectedDetail.costTotal) : "-"} + + {selectedDetail?.costFacility != null ? formatNumber(selectedDetail.costFacility) : "-"} + + {selectedDetail?.costPipe != null ? formatNumber(selectedDetail.costPipe) : "-"} + ------- + {selectedDetail?.costDesign != null ? formatNumber(selectedDetail.costDesign) : "-"} + + {selectedDetail?.costSupervision != null ? formatNumber(selectedDetail.costSupervision) : "-"} + - + {selectedDetail?.costOther != null ? formatNumber(selectedDetail.costOther) : "-"} + + {selectedDetail?.fundingTotal != null ? formatNumber(selectedDetail.fundingTotal) : "-"} + + {selectedDetail?.fundingNational != null ? formatNumber(selectedDetail.fundingNational) : "-"} + + {selectedDetail?.fundingLocal != null ? formatNumber(selectedDetail.fundingLocal) : "-"} + + {selectedDetail?.fundingSelf != null ? formatNumber(selectedDetail.fundingSelf) : "-"} + + {typeof selectedDetail?.fundingRate === "number" ? `${selectedDetail.fundingRate}%` : "-"} + + {selectedDetail?.fundingTotal != null ? formatNumber(selectedDetail.fundingTotal) : "-"} + + {selectedDetail?.fundingNational != null ? formatNumber(selectedDetail.fundingNational) : "-"} + + {selectedDetail?.fundingLocal != null ? formatNumber(selectedDetail.fundingLocal) : "-"} + + {selectedDetail?.fundingSelf != null ? formatNumber(selectedDetail.fundingSelf) : "-"} + + {typeof selectedDetail?.fundingRate === "number" ? `${selectedDetail.fundingRate}%` : "-"} +
+
+ + {!selectedCaseId && showList ? ( +
+ 리스트에서 사업을 선택하면 상세정보가 표시됩니다. +
+ ) : null} +
+
+
+
+ ); + + return createPortal(ui, document.body); +} \ No newline at end of file diff --git a/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx b/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx index a8a7297d3..2a9bbaf9a 100644 --- a/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx +++ b/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx @@ -1,10 +1,11 @@ -// apps/dm/app/(landing)/reuse/apply/register/page.tsx +// apps/dm/app/(landing)/reuse/apply/register/page.tsx (수요정보등록 페이지) "use client"; import { useMemo, useState, useRef } from "react"; import SubPageHeader from "../../_components/SubPageHeader"; import WaterQualityRefModal from "../modal/WaterQualityRefModal"; import CandidateSiteModal, { type CandidateSite } from "../modal/CandidateSiteModal"; +import SimilarCaseModal, { type SimilarCaseRow, type SimilarCaseDetail } from "../modal/SimilarCaseModal"; type PHRange = { min: string; max: string }; @@ -22,6 +23,141 @@ export default function DemandRegisterPage() { const [openCand, setOpenCand] = useState(false); const [selectedCands, setSelectedCands] = useState([]); + // 유사사례 모달 + const [openSimilar, setOpenSimilar] = useState(false); + + // 유사사례 rows (목업) - API 연동 시 여기만 교체 + // const rows: SimilarCaseRow[] = useMemo( + // () => [ + // { + // caseId: "C-001", + // projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업", + // period: "2020.01. ~ 2025.05.", + // supplyFacilityType: "중수도", + // supplyName: "OO새교복지타운", + // supplyCapacity: 0, + // demandName: "OO새교복지타운", + // demandAddress: "OO시 수청로 192", + // facilityCapacity: 50, + // pipeKm: 0, + // purpose: "청소, 화장실, 조경", + // supplyAmt: 50, + // treatment: "MBLM", + // usePurpose: "청소,화장실", + // }, + // { + // caseId: "C-001", + // projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업", + // period: "2020.01. ~ 2025.05.", + // supplyFacilityType: "중수도", + // supplyName: "OO스포츠센터", + // supplyCapacity: 0, + // demandName: "OO스포츠센터", + // demandAddress: "OO시 경기동로 33", + // facilityCapacity: 50, + // pipeKm: 0, + // purpose: "청소, 화장실, 조경", + // supplyAmt: 50, + // treatment: "MBLM", + // usePurpose: "청소,화장실", + // }, + // { + // caseId: "C-002", + // projectName: "OO 엄성저수지 하수처리수 재이용사업", + // period: "2019.12 ~ 2026.01", + // supplyFacilityType: "하수", + // supplyName: "OO 3,4단계 공공하수처리시설", + // supplyCapacity: 70000, + // demandName: "OO천", + // demandAddress: "-", + // facilityCapacity: 0, + // pipeKm: 3, + // purpose: "하천 등 유지용수", + // supplyAmt: 2300, + // treatment: "탈황,총인, 생물산화과,소독", + // usePurpose: "하천 등 유지용수", + // }, + // { + // caseId: "C-002", + // projectName: "OO 엄성저수지 하수처리수 재이용사업", + // period: "2019.12 ~ 2026.01", + // supplyFacilityType: "하수", + // supplyName: "OO 3,4단계 공공하수처리시설", + // supplyCapacity: 70000, + // demandName: "OO저수지", + // demandAddress: "-", + // facilityCapacity: 0, + // pipeKm: 5, + // purpose: "하천 등 유지용수", + // supplyAmt: 7700, + // treatment: "탈황,총인, 생물산화과,소독", + // usePurpose: "하천 등 유지용수", + // }, + // ], + // [] + // ); + + // 유사사례 상세(목업) - caseId로 매핑 + // const detailsByCaseId: Record = useMemo( + // () => ({ + // "C-001": { + // coliform: "불검출", + // residualCl: "0.2이상", + // turbidity: "1.5이하", + // bod: "4.0이하", + // odor: "불쾌하지않을것", + // color: "10이하", + // tn: "-", + // tp: "-", + // ph: "5.8~8.5", + // chloride: "-", + // ec: "-", + // note: "-", + + // costTotal: 4305, + // costFacility: 3518, + // costPipe: undefined, + // costDesign: 350, + // costSupervision: 417, + // costOther: 12, + + // fundingTotal: 4305, + // fundingNational: 3104, + // fundingLocal: 1291, + // fundingSelf: undefined, + // fundingRate: 70.0, + // }, + // "C-002": { + // coliform: "불검출", + // residualCl: "0.15이상", + // turbidity: "2.0이하", + // bod: "5.0이하", + // odor: "무", + // color: "10이하", + // tn: "-", + // tp: "-", + // ph: "6.0~8.5", + // chloride: "-", + // ec: "-", + // note: "-", + + // costTotal: 9800, + // costFacility: 2100, + // costPipe: 1800, + // costDesign: 300, + // costSupervision: 280, + // costOther: 1200, + + // fundingTotal: 9800, + // fundingNational: 4900, + // fundingLocal: 3000, + // fundingSelf: 1900, + // fundingRate: 50, + // }, + // }), + // [] + // ); + // 기본정보 const [reqDate, setReqDate] = useState(today); const [reqQty, setReqQty] = useState(""); @@ -49,264 +185,455 @@ export default function DemandRegisterPage() { const [cand2, setCand2] = useState(""); const [cand3, setCand3] = useState(""); + // 샘플 데이터 + const similarRows: SimilarCaseRow[] = [ + // ---------------- A (2행) ---------------- + { + caseId: "A", + projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업", + period: "2020.01.~2025.05.", + supplyFacilityType: "중수도", + supplyName: "OO폐수재이용", + supplyCapacity: 50, + + demandName: "OO시체육센터", + demandAddress: "OO시 수성로 192", + + facilityCapacity: 50, + pipeKm: 3, + purpose: "조경", + supplyAmt: 50, + treatment: "MBLM", + usePurpose: "청소, 화장실", + }, + // A의 공급처 row 추가(=표 1행 추가) → 나머지 컬럼도 같이 채움 + { + caseId: "A", + projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업", + period: "2020.01.~2025.05.", + supplyFacilityType: "중수도", + supplyName: "OO스포츠센터", + supplyCapacity: 30, + + demandName: "OO시체육센터", + demandAddress: "OO시 수성로 192", + + facilityCapacity: 50, + pipeKm: 3, + purpose: "조경", + supplyAmt: 50, + treatment: "MBLM", + usePurpose: "청소, 화장실", + }, + + // ---------------- B (2행) ---------------- + { + caseId: "B", + projectName: "OO 염성저수지 하수처리수 재이용사업", + period: "2019.12~2026.1", + supplyFacilityType: "하수", + supplyName: "OO3.4단계 공공하수처리시설", + supplyCapacity: 70000, + + demandName: "OO천", + demandAddress: "-", + + facilityCapacity: 0, + pipeKm: 3, + purpose: "하천 등 유지용수", + supplyAmt: 2300, + treatment: "탈황, 총인, 생물산화, 소독", + usePurpose: "하천 등 유지용수", + }, + // B의 공급처 row 추가(=표 1행 추가) + { + caseId: "B", + projectName: "OO 염성저수지 하수처리수 재이용사업", + period: "2019.12~2026.1", + supplyFacilityType: "하수", + supplyName: "OO3.4단계 공공하수처리시설(2라인)", + supplyCapacity: 70000, + + demandName: "OO저수지", + demandAddress: "-", + + facilityCapacity: 0, + pipeKm: 5, + purpose: "하천 등 유지용수", + supplyAmt: 7700, + treatment: "탈황, 총인, 생물산화, 소독", + usePurpose: "하천 등 유지용수", + }, + + // ---------------- C (1행) ---------------- + { + caseId: "C", + projectName: "OO 산업단지 공업용수 재이용사업", + period: "2021.03~2024.12", + supplyFacilityType: "하수", + supplyName: "OO공공하수처리장", + supplyCapacity: 15000, + + demandName: "OO산업단지", + demandAddress: "OO시 산업로 25", + + facilityCapacity: 5000, + pipeKm: 4, + purpose: "공업용수", + supplyAmt: 5000, + treatment: "UF + RO", + usePurpose: "공업용수", + }, + + // ---------------- D (1행) ---------------- + { + caseId: "D", + projectName: "OO 공원 조경용수 재이용사업", + period: "2018.05~2022.11", + supplyFacilityType: "중수도", + supplyName: "OO정수장", + supplyCapacity: 8000, + + demandName: "OO중앙공원", + demandAddress: "OO시 공원로 88", + + facilityCapacity: 1200, + pipeKm: 2, + purpose: "조경용수", + supplyAmt: 1200, + treatment: "모래여과", + usePurpose: "조경", + }, +]; + + const similarDetailsByCaseId: Record = { + A: { + coliform: "불검출", + residualCl: "0.2이상", + turbidity: "1.5이하", + bod: "4.0이하", + odor: "불쾌하지않을것", + color: "10이하", + tn: "-", + tp: "-", + ph: "5.8~8.5", + chloride: "-", + ec: "-", + note: "-", + costTotal: 4305, + costFacility: 3518, + costPipe: 0, + costDesign: 350, + costSupervision: 417, + costOther: 12, + fundingTotal: 4305, + fundingNational: 3104, + fundingLocal: 1291, + fundingSelf: 0, + fundingRate: 70.0, + }, + B: { + coliform: "-", + residualCl: "-", + turbidity: "-", + bod: "-", + odor: "-", + color: "-", + tn: "-", + tp: "-", + ph: "-", + chloride: "-", + ec: "-", + note: "-", + costTotal: 0, + costFacility: 0, + costPipe: 0, + costDesign: 0, + costSupervision: 0, + costOther: 0, + fundingTotal: 0, + fundingNational: 0, + fundingLocal: 0, + fundingSelf: 0, + fundingRate: 0, + }, + C: { + coliform: "불검출", + residualCl: "0.2이상", + turbidity: "1.5이하", + bod: "4.0이하", + odor: "불쾌하지않을것", + color: "10이하", + tn: "-", + tp: "-", + ph: "5.8~8.5", + chloride: "-", + ec: "-", + note: "-", + costTotal: 4305, + costFacility: 3518, + costPipe: 0, + costDesign: 350, + costSupervision: 417, + costOther: 12, + fundingTotal: 4305, + fundingNational: 3104, + fundingLocal: 1291, + fundingSelf: 0, + fundingRate: 70.0, + }, + D: { + coliform: "-", + residualCl: "-", + turbidity: "-", + bod: "-", + odor: "-", + color: "-", + tn: "-", + tp: "-", + ph: "-", + chloride: "-", + ec: "-", + note: "-", + costTotal: 0, + costFacility: 0, + costPipe: 0, + costDesign: 0, + costSupervision: 0, + costOther: 0, + fundingTotal: 0, + fundingNational: 0, + fundingLocal: 0, + fundingSelf: 0, + fundingRate: 0, + }, + }; + return ( <> - -
- - {/* 서브 타이틀 + 브레드크럼 */} - - - {/* 컨텐츠 영역 */} -
- - {/* 기본정보 테이블 */} -
- - - - - - - - - - - - - - - - - - - - - -
- - {/* 요구수질 섹션 타이틀 바 */} -
-
- 요구수질 - - {/* 수질기준참고 모달 */} - setOpenRef(false)} - imageSrc="/images/landing/water_quality_ref_1.png" - /> -
-
- - {/* 요구수질 테이블 */} -
- {/* 1행 */} - - - -
- } - rightLabel="총질소(T-N)(mg/L)" - rightControl={ - - } - /> - - - {/* 2행 */} - } - rightLabel="총인(T-P)(mg/L)" - rightControl={ - - } - /> - - - {/* 3행 */} - - } - rightLabel="수소이온농도(pH)" - rightControl={ -
- setPh((p) => ({ ...p, min: v }))} - className="w-32" - /> - ~ - setPh((p) => ({ ...p, max: v }))} - className="w-32" - /> -
- } - /> - - - {/* 4행 */} - - 생물화학적산소요구량 -
- (BOD)(mg/L) - - } - leftRequired - leftControl={} - rightLabel="염화물(mgCL/L)" - rightControl={} - /> - - - {/* 5행 */} - 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" - > - - - - - } - rightLabel="전기전도도(µs/cm)" - rightControl={} - /> - - - {/* 6행 */} - } - rightLabel="비고" - rightControl={ -