diff --git a/apps/dm_new/.env.local b/apps/dm_new/.env.local new file mode 100644 index 000000000..88b6f8067 --- /dev/null +++ b/apps/dm_new/.env.local @@ -0,0 +1,26 @@ +NEXT_PUBLIC_SITE_ID = DM +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_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 +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_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 +NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM= diff --git a/apps/dm_new/.env.mindone b/apps/dm_new/.env.mindone new file mode 100644 index 000000000..da6f80177 --- /dev/null +++ b/apps/dm_new/.env.mindone @@ -0,0 +1,24 @@ +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_CLAIM_NAME = Authorization +NEXT_PUBLIC_AUTH_USER_ID = token-id +NEXT_PUBLIC_ACCESS_TOKEN = access-token +NEXT_PUBLIC_REFRESH_TOKEN = refresh-token +NEXT_PUBLIC_WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1 +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 +NEXT_PRIVATE_WR_ADDRESS_KEY = devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM= diff --git a/apps/dm_new/.env.production b/apps/dm_new/.env.production new file mode 100644 index 000000000..5a401c89a --- /dev/null +++ b/apps/dm_new/.env.production @@ -0,0 +1,24 @@ +# 공단 운영 +NEXT_PUBLIC_SITE_ID = DM +NEXT_PUBLIC_PORT = 3000 +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 = 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_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_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_FINDADRESS_URL=https://business.juso.go.kr/addrlink/addrLinkApi.do +NEXT_PRIVATE_WR_ADDRESS_KEY = U01TXOFVVEgyMDI1MDgyODE0MTAxNzExNjEyMjA= diff --git a/apps/dm_new/app/(landing)/reuse/_components/CaseTabsCarousel.tsx b/apps/dm_new/app/(landing)/reuse/_components/CaseTabsCarousel.tsx new file mode 100644 index 000000000..2d936ffc5 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/CaseTabsCarousel.tsx @@ -0,0 +1,519 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +/** + * CaseTabsCarousel.tsx (FINAL OPTIMIZED) + * - 탭별 데이터 + * - 카드 perPage(1/2/3) 반응형 + * - 카드 perPage개 = 1그룹, 좌/우 클릭 시 1그룹 단위 이동 + * - 3그룹 가상화(prev/cur/next)로 DOM 최소화 + * - px 기반 트랙 이동 + "측정 박스 분리"로 좌/우 비침 방지 + * - stale state 제거(방향/인덱스 ref 관리)로 슬라이드 안정성 강화 + * - BUGFIX 1) onTransitionEnd 버블링 방지 (target===currentTarget, transform만 처리) + * - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리 + */ + +type TabKey = "rain" | "reuse" | "sewage" | "global"; + +type CaseItem = { + id: string; + title: string; + desc: string; + imageSrc: string; + href?: string; +}; + +const TABS: { key: TabKey; label: string }[] = [ + { key: "rain", label: "빗물 재이용 사례" }, + { key: "reuse", label: "중수 재이용 사례" }, + { key: "sewage", label: "하수 재이용 사례" }, + { key: "global", label: "해외 재이용 사례" }, +]; + +// 실제 데이터로 교체해서 쓰세요(구조 유지) +const MOCK: Record = { + rain: [ + { + id: "rain-0", + title: "경기도 수원시 빗물이용과 중수도", + desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…", + imageSrc: "/images/landing/rain_01.png", + href: "/reuse/business/rain/suwon", + }, + // { + // id: "rain-2", + // title: "경기도 수원시 빗물이용과 중수도", + // desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…", + // imageSrc: "/images/landing/rain_01.png", + // href: "#", + // }, + ], + reuse: Array.from({ length: 6 }).map((_, i) => ({ + id: `reuse-${i}`, + title: `중수 재이용 사례 ${i + 1}`, + desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", + imageSrc: "/images/landing/sample_case.png", + href: "#", + })), + sewage: [ + { + id: "sewage-1", + title: "경기도 파주시 하수 재이용", + desc: "운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에...", + imageSrc: "/images/landing/sewage_01.png", + href: "#", + }, + { + id: "sewage-2", + title: "충남보령 하수재이용", + desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...", + imageSrc: "/images/landing/sewage_02.png", + href: "#", + }, + { + id: "sewage-3", + title: "여수시 하수처리수 재이용", + desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...", + imageSrc: "/images/landing/sewage_03.png", + href: "#", + }, + ...Array.from({ length: 6 }).map((_, i) => ({ + id: `sewage-x-${i}`, + title: `하수 재이용 사례 ${i + 4}`, + desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", + imageSrc: "/images/landing/sample_case.png", + href: "#", + })), + ], + global: Array.from({ length: 6 }).map((_, i) => ({ + id: `global-${i}`, + title: `해외 재이용 사례 ${i + 1}`, + desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", + imageSrc: "/images/landing/sample_case.png", + href: "#", + })), +}; + +/* ---------------- utils ---------------- */ +function chunk(arr: T[], size: number) { + const res: T[][] = []; + if (size <= 0) return res; + for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size)); + return res; +} +function mod(n: number, m: number) { + return ((n % m) + m) % m; +} +function usePrefersReducedMotion() { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const onChange = () => setReduced(mq.matches); + onChange(); + mq.addEventListener?.("change", onChange); + return () => mq.removeEventListener?.("change", onChange); + }, []); + return reduced; +} + +/** + * wrapRef: perPage 계산용 + * clipRef: "패딩 없는 측정 박스" 폭(px) 계산용 + */ +function useCarouselMeasure() { + const wrapRef = useRef(null); + const clipRef = useRef(null); + + const [perPage, setPerPage] = useState(3); + const [clipWidth, setClipWidth] = useState(0); + + // perPage: container width로 반응형 결정 (rAF로 디바운스) + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + + let raf = 0; + + const calc = () => { + const w = el.clientWidth; + if (w < 640) setPerPage(1); + else if (w < 1024) setPerPage(2); + else setPerPage(3); + }; + + const ro = new ResizeObserver(() => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(calc); + }); + + calc(); + ro.observe(el); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, []); + + // clipWidth: 패딩 없는 박스에서 width를 정확히 측정 (sub-pixel 반올림) + useEffect(() => { + const el = clipRef.current; + if (!el) return; + + let raf = 0; + + const calc = () => { + const w = Math.round(el.getBoundingClientRect().width); + setClipWidth(w); + }; + + const ro = new ResizeObserver(() => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(calc); + }); + + calc(); + ro.observe(el); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, []); + + return { wrapRef, clipRef, perPage, clipWidth }; +} + +/* ---------------- component ---------------- */ +export default function CaseTabsCarousel() { + const [active, setActive] = useState("rain"); + + // 탭별 마지막 그룹 인덱스 기억 + const tabIndexRef = useRef>({ + rain: 0, + reuse: 0, + sewage: 0, + global: 0, + }); + + const reducedMotion = usePrefersReducedMotion(); + const { wrapRef, clipRef, perPage, clipWidth } = useCarouselMeasure(); + + const items = useMemo(() => MOCK[active] ?? [], [active]); + const groups = useMemo(() => chunk(items, perPage), [items, perPage]); + const groupCount = groups.length; + + const [cur, setCur] = useState(0); + const curRef = useRef(0); + + // px 기반 트랙 + const [trackPx, setTrackPx] = useState(0); + const [anim, setAnim] = useState(false); + const [busy, setBusy] = useState(false); + + // 방향 ref (onTransitionEnd에서 trackPx 비교하지 않음) + const dirRef = useRef<-1 | 1 | 0>(0); + + // cur 최신값 유지 + useEffect(() => { + curRef.current = cur; + }, [cur]); + + /** + * 리셋 로직 분리: + * - 탭/perPage/groupCount 바뀔 때만 "인덱스/상태" 리셋 + * - clipWidth 변화는 별도 effect에서 "busy 아닐 때만" 중앙(-W)로 배치 + */ + useLayoutEffect(() => { + const saved = tabIndexRef.current[active] ?? 0; + const nextCur = groupCount ? mod(saved, groupCount) : 0; + + dirRef.current = 0; + setCur(nextCur); + setBusy(false); + setAnim(false); + }, [active, perPage, groupCount]); + + // clipWidth 변화로 트랙 위치를 잡되, 슬라이드 중(busy)엔 덮어쓰지 않음 + useLayoutEffect(() => { + if (busy) return; + if (clipWidth > 0) setTrackPx(-clipWidth); + else setTrackPx(0); + }, [clipWidth, busy]); + + // 3그룹 가상화 + const viewGroups = useMemo(() => { + if (!groupCount) return [[], [], []] as CaseItem[][]; + return [ + groups[mod(cur - 1, groupCount)] ?? [], + groups[mod(cur, groupCount)] ?? [], + groups[mod(cur + 1, groupCount)] ?? [], + ]; + }, [groups, cur, groupCount]); + + const canSlide = groupCount > 1 && clipWidth > 0; + + const go = useCallback( + (dir: -1 | 1) => { + if (!canSlide) return; + if (busy) return; + + dirRef.current = dir; + + setBusy(true); + setAnim(!reducedMotion); + + // 중앙(-W) 기준: prev=0, next=-2W + setTrackPx(dir === 1 ? -2 * clipWidth : 0); + + // reduced motion이면 즉시 확정 + if (reducedMotion) { + const nextCur = mod(curRef.current + dir, groupCount); + tabIndexRef.current[active] = nextCur; + + setCur(nextCur); + setAnim(false); + setTrackPx(-clipWidth); + setBusy(false); + dirRef.current = 0; + } + }, + [active, busy, canSlide, clipWidth, groupCount, reducedMotion] + ); + + // BUGFIX 1) 자식 transitionend 버블링 방지 + transform만 처리 + const onTransitionEnd = useCallback( + (e: React.TransitionEvent) => { + // 자식 transitionend 버블링 방지 + if (e.target !== e.currentTarget) return; + if (e.propertyName !== "transform") return; + + if (!busy || reducedMotion) return; + + const dir = dirRef.current; + if (dir !== 1 && dir !== -1) return; + + const nextCur = mod(curRef.current + dir, groupCount); + tabIndexRef.current[active] = nextCur; + + // 1) 먼저 transition을 끄고(중요) + setAnim(false); + + // 2) cur를 회전 + 트랙을 즉시 중앙(-W)로 스냅 + // (이 순간은 "무조건 애니메이션 없이" 처리되어야 함) + setCur(nextCur); + setTrackPx(-clipWidth); + + // 3) 다음 프레임(2번 rAF)에서 다시 애니메이션 켜고 busy 해제 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setAnim(!reducedMotion); + setBusy(false); + dirRef.current = 0; + }); + }); + }, + [active, busy, clipWidth, groupCount, reducedMotion] +); + + const goDot = useCallback( + (idx: number) => { + if (!groupCount) return; + if (busy) return; + + const target = mod(idx, groupCount); + tabIndexRef.current[active] = target; + + dirRef.current = 0; + setCur(target); + setAnim(false); + if (clipWidth > 0) setTrackPx(-clipWidth); + }, + [active, busy, clipWidth, groupCount] + ); + + const trackStyle: React.CSSProperties = useMemo( + () => ({ + transform: `translate3d(${trackPx}px, 0, 0)`, + transition: anim ? "transform 700ms cubic-bezier(.22,.61,.36,1)" : "none", + willChange: busy ? "transform" : undefined, + }), + [trackPx, anim, busy] + ); + + return ( +
+ {/* 탭 바 */} +
+
+
+ {TABS.map((t) => { + const isActive = t.key === active; + + return ( + + ); + })} +
+
+
+ + {/* 슬라이더 */} +
+
+ {/* 좌우 버튼 */} + + + + + {/* 중요: clip(가림)과 measure(측정) 분리 */} +
+
+ {/* 측정은 패딩 없는 박스에서 */} +
+ {/* track */} +
+ {viewGroups.map((group, gi) => ( +
+ {/* 그룹 내부: perPage 컬럼 */} +
+ {(group ?? []).map((c) => ( + + ))} +
+
+ ))} +
+
+
+
+ + {/* 도트 */} +
+ {Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => ( +
+
+
+
+ ); +} diff --git a/apps/dm_new/app/(landing)/reuse/_components/ImageSlider.tsx b/apps/dm_new/app/(landing)/reuse/_components/ImageSlider.tsx new file mode 100644 index 000000000..6c50d5a5c --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/ImageSlider.tsx @@ -0,0 +1,167 @@ +// apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +type Slide = { src: string; alt?: string }; + +type Props = { + slides: Slide[]; + className?: string; + maxWidthClassName?: string; // 예: "max-w-[760px]" +}; + +export default function CaseImageSlider({ + slides, + className = "", + maxWidthClassName = "max-w-[760px]", +}: Props) { + const safeSlides = useMemo(() => slides.filter(Boolean), [slides]); + const [idx, setIdx] = useState(0); + const [dragging, setDragging] = useState(false); + + const startX = useRef(0); + const deltaX = useRef(0); + + const total = safeSlides.length; + const canSlide = total > 1; + + const go = (dir: number) => { + if (!canSlide) return; + setIdx((prev) => { + const next = prev + dir; + if (next < 0) return total - 1; + if (next >= total) return 0; + return next; + }); + }; + + // ← → 키보드 지원(선택) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!canSlide) return; + if (e.key === "ArrowLeft") go(-1); + if (e.key === "ArrowRight") go(1); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [canSlide, total]); + + // 드래그(마우스/터치)로 넘기기 + const onPointerDown = (e: React.PointerEvent) => { + if (!canSlide) return; + setDragging(true); + startX.current = e.clientX; + deltaX.current = 0; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging) return; + deltaX.current = e.clientX - startX.current; + }; + + const onPointerUp = () => { + if (!dragging) return; + setDragging(false); + + const threshold = 60; // 넘김 감도 + if (deltaX.current > threshold) go(-1); + else if (deltaX.current < -threshold) go(1); + + deltaX.current = 0; + }; + + if (!total) return null; + + return ( +
+
+
+ {/* 좌측 버튼 */} + + + + {/* 우측 버튼 */} + + + + {/* 뷰포트 */} +
+
+ {safeSlides.map((s, i) => ( +
+ {s.alt +
+ ))} +
+
+
+ + {/* 도트 */} + {canSlide && ( +
+ {safeSlides.map((_, i) => { + const active = i === idx; + return ( +
+ )} +
+
+ ); +} diff --git a/apps/dm_new/app/(landing)/reuse/_components/Lnb.tsx b/apps/dm_new/app/(landing)/reuse/_components/Lnb.tsx new file mode 100644 index 000000000..c7d834844 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/Lnb.tsx @@ -0,0 +1,171 @@ +// apps/dm/app/(landing)/reuse/_components/Lnb.tsx +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; + +export type LnbItem = { + label: string; + href?: string; // href 없는 부모(2뎁스 컨테이너)도 허용 + children?: LnbItem[]; +}; + +function isExactActive(pathname: string, href?: string) { + if (!href) return false; + return pathname === href; +} + +export default function Lnb({ title, items }: { title: string; items: LnbItem[] }) { + const pathname = usePathname(); + + // ✅ 현재 경로가 속한 2depth 그룹을 기본 open + const defaultOpenLabel = useMemo(() => { + const found = items.find((it) => it.children?.some((c) => isExactActive(pathname, c.href))); + return found?.label ?? null; + }, [items, pathname]); + + // ✅ 아코디언: 한 번에 하나만 열리도록 openLabel 1개만 관리 + const [openLabel, setOpenLabel] = useState(defaultOpenLabel); + + return ( + + ); +} diff --git a/apps/dm_new/app/(landing)/reuse/_components/LoginStatus.tsx b/apps/dm_new/app/(landing)/reuse/_components/LoginStatus.tsx new file mode 100644 index 000000000..4ddc08812 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/LoginStatus.tsx @@ -0,0 +1,51 @@ +// apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function LoginStatus() { + const router = useRouter(); + const [userName, setUserName] = useState(null); + + useEffect(() => { + // 예시: 로그인 시 저장한 값 + const name = localStorage.getItem("userName"); + setUserName(name); + }, []); + + const handleLogout = () => { + localStorage.removeItem("accessToken"); + localStorage.removeItem("userName"); + router.refresh(); // 헤더 상태 갱신 + }; + + // 🔹 로그인 전 + if (!userName) { + return ( + + 로그인 + → + + ); + } + + // 🔹 로그인 후 + return ( +
+ + {userName}님 환영합니다. + + +
+ ); +} diff --git a/apps/dm_new/app/(landing)/reuse/_components/SideIndicator.tsx b/apps/dm_new/app/(landing)/reuse/_components/SideIndicator.tsx new file mode 100644 index 000000000..8f4cea164 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/SideIndicator.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import styles from "../reuse.module.css"; + +export interface SideIndicatorItem { + id: string; + label: string; // 상단/공용 라벨 + sideLabel?: string; // 사이드 인디게이터 전용 라벨(옵션) +} + +export default function SideIndicator({ + items, + activeId, +}: { + items: SideIndicatorItem[]; + activeId: string; +}) { + const [manualActiveId, setManualActiveId] = useState(null); + const clearTimerRef = useRef(null); + + const currentId = (manualActiveId ?? activeId ?? "main") || "main"; + + // ✅ manualActiveId가 observer(activeId)와 불일치하기 시작하면 즉시 해제 (stuck 방지) + useEffect(() => { + if (!manualActiveId) return; + if (activeId && activeId !== manualActiveId) setManualActiveId(null); + }, [activeId, manualActiveId]); + + // ✅ fallback: 스무스 스크롤 시간이 길어도 manual이 오래 남지 않게 타임아웃 해제 + useEffect(() => { + return () => { + if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current); + }; + }, []); + + const scrollTo = (id: string) => { + setManualActiveId(id); + + // 기존 타이머 제거 후 새로 설정 + if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current); + clearTimerRef.current = window.setTimeout(() => { + setManualActiveId(null); + }, 1600); + + if (id === "main") { + window.scrollTo({ top: 0, behavior: "smooth" }); + return; + } + + document.getElementById(id)?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }; + + return ( + + ); +} diff --git a/apps/dm_new/app/(landing)/reuse/_components/SubPageHeader.tsx b/apps/dm_new/app/(landing)/reuse/_components/SubPageHeader.tsx new file mode 100644 index 000000000..fc3bade23 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/SubPageHeader.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; + +export type Crumb = { label: string; href?: string }; + +type Props = { + title: string; + crumbs: Crumb[]; + note?: string; + underlineWidthClass?: string; + className?: string; + wrapClassName?: string; + hideFooterLine?: boolean; + hideGuideText?: boolean; +}; + +export default function SubPageHeader({ + title, + crumbs, + note = "* 는 필수 값입니다.", + underlineWidthClass = "w-40", + className = "", + wrapClassName = "", + hideFooterLine = false, + hideGuideText = false, +}: Props) { + return ( +
+
+
+ {/* 좌측 타이틀 */} +
+

{title}

+
+
+ + {/* 우측 브레드크럼 */} + +
+ {/* 하단 라인 + 안내문 */} + {!hideGuideText && ( +
+ * + {note.replace("*", "")} +
+ )} +
+
+ ); +} diff --git a/apps/dm_new/app/(landing)/reuse/_components/TopNav.tsx b/apps/dm_new/app/(landing)/reuse/_components/TopNav.tsx new file mode 100644 index 000000000..078d5e0d4 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/TopNav.tsx @@ -0,0 +1,465 @@ +// apps/dm/app/(landing)/reuse/_components/TopNav.tsx +"use client"; + +import Link from "next/link"; +import { useEffect, useId, useState } from "react"; +import { useRouter } from "next/navigation"; + +const NAV_ITEMS = [ + { href: "/reuse/intro/law", label: "물 재이용 소개" }, + { href: "/reuse/cases/rain", label: "물 재이용 현황" }, + { href: "/reuse/business/rain/suwon", label: "물 재이용 사례" }, + { href: "/reuse/apply/place", label: "수요신청" }, +]; + +// ✅ 필요 시 실제 “수요처등록” 페이지로 경로만 교체하세요. +const REGISTER_HREF = "/reuse/apply/place"; + +// ✅ 로그인 정보 저장 키(원하는대로 변경 가능) +const LS_TOKEN_KEY = "reuse_accessToken"; +const LS_USER_KEY = "reuse_userName"; + +type LoginModalProps = { + open: boolean; + onClose: () => void; + onSuccess?: (payload: { userName: string; accessToken?: string }) => void; +}; + +type FieldKey = "bizNo" | "workerNo" | "pw"; + +function isBizNoValid(v: string) { + // 예: 123-45-67890 + return /^\d{3}-\d{2}-\d{5}$/.test(v); +} + +function inputCls(opts: { hasError?: boolean; disabled?: boolean }) { + const base = + "w-full h-[48px] rounded-[4px] border px-5 text-[18px] outline-none"; + if (opts.disabled) return `${base} border-gray-200 bg-gray-50 text-gray-400`; + if (opts.hasError) + return `${base} border-red-500 focus:border-red-500 ring-1 ring-red-400`; + return `${base} border-gray-300 focus:border-sky-400`; +} + +function DemandLoginModal({ open, onClose, onSuccess }: LoginModalProps) { + const titleId = useId(); + const router = useRouter(); + + const [bizNo, setBizNo] = useState(""); + const [workerNo, setWorkerNo] = useState(""); + const [noWorker, setNoWorker] = useState(false); + const [pw, setPw] = useState(""); + + // ✅ 유효성/서버 에러 표시용 + const [errors, setErrors] = useState>>({}); + const [touched, setTouched] = useState>>({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + + // 스크롤 잠금 + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + // 모달 오픈 시 에러/터치 초기화 + setErrors({}); + setTouched({}); + + return () => { + window.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prev; + }; + }, [open, onClose]); + + useEffect(() => { + if (noWorker) { + setWorkerNo(""); + setErrors((p) => { + const { workerNo: _w, ...rest } = p; + return rest; + }); + } + }, [noWorker]); + + if (!open) return null; + + const validate = () => { + const next: Partial> = {}; + + const b = bizNo.trim(); + const w = workerNo.trim(); + const p = pw.trim(); + + if (!b) next.bizNo = "사업자등록번호를 입력해주세요."; + else if (!isBizNoValid(b)) + next.bizNo = "사업자등록번호 형식이 올바르지 않습니다. (예: 123-45-67890)"; + + if (!noWorker && !w) next.workerNo = "종사자등록번호를 입력해주세요."; + + if (!p) next.pw = "비밀번호를 입력해주세요."; + + setErrors(next); + return next; + }; + + const markTouched = (key: FieldKey) => + setTouched((p) => ({ ...p, [key]: true })); + + const markAllTouched = () => + setTouched({ bizNo: true, workerNo: true, pw: true }); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + markAllTouched(); + + const v = validate(); + if (Object.keys(v).length) return; + + try { + setSubmitting(true); + + // ✅ TODO: 실제 로그인 API 연결 지점 + // const res = await fetch("/api/demand/login", { + // method: "POST", + // headers: { "Content-Type": "application/json" }, + // body: JSON.stringify({ bizNo, workerNo: noWorker ? null : workerNo, pw }), + // }); + // if (!res.ok) { ... } + // const data = await res.json(); + // onSuccess?.({ userName: data.userName, accessToken: data.token }); + + // --- 데모: 비밀번호가 "1234"면 성공 처리(원하면 제거) --- + const serverOk = pw.trim() === "1234"; + if (!serverOk) { + setErrors((p) => ({ ...p, pw: "잘못된 비밀번호입니다. (데모: 1234 입력 시 성공)" })); + setTouched((p) => ({ ...p, pw: true })); + return; + } + + onSuccess?.({ userName: "마인드원", accessToken: "demo_token" }); + onClose(); + router.refresh(); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ {/* Backdrop */} + + +

+ 수요처 로그인 +

+ + {/* title underline gradient bar */} +
+
+ + {/* Body */} +
+
+ {/* 사업자 등록번호 */} +
+ + { + setBizNo(e.target.value); + setErrors((p) => ({ ...p, bizNo: undefined })); + }} + onBlur={() => { + markTouched("bizNo"); + validate(); + }} + placeholder="사업자등록번호를 입력하세요. (예: 123-45-67890)" + className={inputCls({ hasError: !!(touched.bizNo && errors.bizNo) })} + /> + {touched.bizNo && errors.bizNo && ( +

+ {errors.bizNo} +

+ )} +
+ + {/* 종사자등록번호 + 해당없음 */} +
+
+ + + +
+ + { + setWorkerNo(e.target.value); + setErrors((p) => ({ ...p, workerNo: undefined })); + }} + onBlur={() => { + markTouched("workerNo"); + validate(); + }} + disabled={noWorker} + placeholder="종사자등록번호를 입력하세요." + className={inputCls({ + disabled: noWorker, + hasError: !noWorker && !!(touched.workerNo && errors.workerNo), + })} + /> + + {!noWorker && touched.workerNo && errors.workerNo && ( +

+ {errors.workerNo} +

+ )} +
+ + {/* 비밀번호 */} +
+ + { + setPw(e.target.value); + setErrors((p) => ({ ...p, pw: undefined })); + }} + onBlur={() => { + markTouched("pw"); + validate(); + }} + placeholder="비밀번호를 입력하세요." + className={inputCls({ hasError: !!(touched.pw && errors.pw) })} + /> + {touched.pw && errors.pw && ( +

+ {errors.pw} +

+ )} +
+ + {/* 로그인 버튼 */} + + + {/* 비밀번호 초기화 요청 */} + + + {/* 하단 안내 박스 + 수요처등록 버튼 */} +
+
+
+ 수요처를 등록하지 않았다면 +
+
+ 먼저 수요처등록을 해주세요. +
+
+ + +
+
+
+
+ + + ); +} + +export default function TopNav() { + const router = useRouter(); + + const [loginOpen, setLoginOpen] = useState(false); + const [userName, setUserName] = useState(null); + + // ✅ 초기 로드 시 로그인 상태 반영 + useEffect(() => { + const name = localStorage.getItem(LS_USER_KEY); + setUserName(name); + }, []); + + const openLogin = () => setLoginOpen(true); + + const logout = () => { + localStorage.removeItem(LS_TOKEN_KEY); + localStorage.removeItem(LS_USER_KEY); + setUserName(null); + router.refresh(); + }; + + return ( + <> +
+ {/* ✅ 좌/중/우 정렬이 되도록 컨테이너 구성 */} +
+
+ {/* 좌측: 로고 */} +
+ + HASUON + +
+ + {/* 중앙: 상단 메뉴 */} + + + {/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */} +
+ {!userName ? ( + + ) : ( + <> + + {userName}님 환영합니다. + + + + )} +
+
+
+
+ + {/* ✅ 로그인 모달 */} + setLoginOpen(false)} + onSuccess={({ userName, accessToken }) => { + // ✅ 로그인 성공 처리: 저장 + 헤더 갱신 + localStorage.setItem(LS_USER_KEY, userName); + if (accessToken) localStorage.setItem(LS_TOKEN_KEY, accessToken); + setUserName(userName); + setLoginOpen(false); + router.refresh(); + }} + /> + + ); +} diff --git a/apps/dm_new/app/(landing)/reuse/_constants/lnb.ts b/apps/dm_new/app/(landing)/reuse/_constants/lnb.ts new file mode 100644 index 000000000..6f9706e68 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_constants/lnb.ts @@ -0,0 +1,52 @@ +// apps/dm/app/(landing)/reuse/_constants/lnb.ts +import type { LnbItem } from "../_components/Lnb"; + +export const LNB_DEMAND: LnbItem[] = [ + { href: "/reuse/apply/place", label: "수요처등록" }, + { href: "/reuse/apply/register", label: "수요정보등록" }, + { href: "/reuse/apply/status", label: "수요신청현황" }, +]; + +export const LNB_INTRO = [ + { label: "물 재이용 법", href: "/reuse/intro/law" }, + { label: "빗물 이용시설", href: "/reuse/intro/rain" }, + { label: "중수도", href: "/reuse/intro/greywater" }, + { label: "하수처리수", href: "/reuse/intro/treated" }, +]; + +export const LNB_CASES = [ + { label: "빗물이용시설(현황)", href: "/reuse/cases/rain" }, + { label: "중수도(현황)", href: "/reuse/cases/greywater" }, + { label: "하수처리수 재이용시설(현황)", href: "/reuse/cases/treated" }, +]; + +// 물 재이용 사례처럼 2depth가 있는 메뉴 +export const LNB_BUSINESS: LnbItem[] = [ + { + label: "빗물 재이용 사례", + children: [ + { label: "경기도 수원시", href: "/reuse/business/rain/suwon" }, + // { label: "충청남도 보령시", href: "/reuse/business/rain/bolyeong" }, + // { label: "충청북도 청주시", href: "/reuse/business/rain/cheongju" }, + ], + }, + { + label: "중수 재이용 사례", + children: [{ label: "사례 목록", href: "/reuse/business/graywater" }], + }, + { + label: "하수 재이용 사례", + children: [ + { label: "파주시", href: "/reuse/business/sewage/paju" }, + { label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" }, + { label: "여수", href: "/reuse/business/sewage/yeosu" }, + { label: "청주", href: "/reuse/business/sewage/cheongju" }, + { label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" }, + { label: "구리시 도로자동청소시스템", href: "/reuse/business/sewage/guri" }, + ], + }, + { + label: "해외 재이용 사례", + children: [{ label: "사례 목록", href: "/reuse/business/global" }], + }, +]; diff --git a/apps/dm_new/app/(landing)/reuse/_hooks/useScrollSpy.ts b/apps/dm_new/app/(landing)/reuse/_hooks/useScrollSpy.ts new file mode 100644 index 000000000..3a253e95d --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_hooks/useScrollSpy.ts @@ -0,0 +1,55 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; + +export function useScrollSpy(sectionIds: string[], options?: IntersectionObserverInit) { + const [activeId, setActiveId] = useState(sectionIds[0] ?? ""); + const ratiosRef = useRef>(new Map()); + + useEffect(() => { + const els = sectionIds + .map((id) => document.getElementById(id)) + .filter(Boolean) as HTMLElement[]; + + if (!els.length) return; + + const DEFAULT_ROOT_MARGIN = "83px 0px -65% 0px"; + + // 초기값 세팅(안정화) + ratiosRef.current.clear(); + els.forEach((el) => ratiosRef.current.set(el.id, 0)); + + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) { + const id = (e.target as HTMLElement).id; + ratiosRef.current.set(id, e.isIntersecting ? e.intersectionRatio : 0); + } + + // ✅ 전체 섹션 중 가장 ratio 큰 항목을 active로 + let bestId = activeId; + let bestRatio = -1; + + for (const [id, ratio] of ratiosRef.current.entries()) { + if (ratio > bestRatio) { + bestRatio = ratio; + bestId = id; + } + } + + if (bestId) setActiveId(bestId); + }, + { + root: null, + threshold: [0, 0.01, 0.1, 0.25, 0.5, 0.75], + rootMargin: DEFAULT_ROOT_MARGIN, + ...options, + } + ); + + els.forEach((el) => io.observe(el)); + return () => io.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sectionIds.join("|"), options?.rootMargin, JSON.stringify(options?.threshold)]); + + return activeId; +} diff --git a/apps/dm_new/app/(landing)/reuse/_sections/Cases.tsx b/apps/dm_new/app/(landing)/reuse/_sections/Cases.tsx new file mode 100644 index 000000000..3d91bb5d5 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_sections/Cases.tsx @@ -0,0 +1,65 @@ +// apps/dm/app/(landing)/reuse/_sections/Cases.tsx +"use client"; + +export default function Cases() { + return ( +
+
+
+ + {/* 좌측 : 카피 영역 */} +
+
+

+ 빗물+중수도+하수도 +

+ +

+ + 데이터로 보는 + + + 물 재이용 현황 + +

+ + +
+
+ + {/* 우측 : 이미지 카드 */} +
+
+ 물 재이용 현황 데이터 테이블 +
+
+ +
+
+
+ ); +} \ 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 new file mode 100644 index 000000000..8f93bdd72 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_sections/Hero.tsx @@ -0,0 +1,29 @@ +// apps/dm/app/(landing)/reuse/_sections/Hero.tsx +import styles from "../reuse.module.css"; + +export default function Hero() { + return ( +
+
+
+ +
+
+

물 재이용

+

+ 빗물, 오수, 하수처리수, 폐수처리수, 발전소 온배수를 재이용처리하여
+ 생활, 공업, 농업, 조경, 하천유지 등의 용도로 재이용합니다. +

+

+ 전국 하수 재이용률 15.6%(2024년 통계 기준),
+ 물재이용 활성화를 위한 재이용 수요신청, 매칭을 추진하고 있습니다. +

+
+
+
+ + 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 new file mode 100644 index 000000000..070d9ba91 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_sections/IntroCards.tsx @@ -0,0 +1,86 @@ +// apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx +"use client"; + +import Link from "next/link"; + +type IntroCardItem = { + title: string; + desc: string; + image: string; + href: string; +}; + +const INTRO_CARDS: IntroCardItem[] = [ + { + title: "물 재이용 법", + desc: "물의 재이용 촉진 및 지원에 관한 법률을 시행하고, 일부 법률은 개정되었습니다.", + image: "/images/landing/card_01.jpg", + href: "/reuse/intro/law", + }, + { + title: "친환경 수자원 빗물", + desc: "가뭄을 극복하고 홍수를 예방하기 위해 비가 내릴 때 빗물을 모아두었다가 사용합니다.", + image: "/images/landing/card_02.jpg", + href: "/reuse/intro/rain", + }, + { + title: "알뜰하게 재이용되는 중수", + desc: "한번 사용한 물을 중수도 시설을 통해 재생하여 개별적 또는 지역적으로 재이용할 수 있습니다.", + image: "/images/landing/card_03.jpg", + href: "/reuse/intro/greywater", + }, + { + title: "하수처리수 재이용", + desc: "사용하고 버려진 공공하수처리장으로 모여진 물을 용도에 맞는 처리 후 재이용 할 수 있습니다.", + image: "/images/landing/card_04.jpg", + href: "/reuse/intro/treated", + }, +]; + +export default function IntroCards() { + return ( +
+
+
+
+

물 재이용 소개

+
+
+

+ 지속 가능한 미래를 위한 약속, 깨끗한 물의 새로운 순환을 시작합니다. +

+
+ +
+
+ {INTRO_CARDS.map((card, idx) => ( + + {/* 배경 이미지 (이미 그라디언트 포함) */} + {card.title} + + {/* 텍스트 영역 */} +
+

+ {card.title} +

+

+ {card.desc} +

+
+ + ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/dm_new/app/(landing)/reuse/_sections/ProcessFlow.tsx b/apps/dm_new/app/(landing)/reuse/_sections/ProcessFlow.tsx new file mode 100644 index 000000000..761a79201 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_sections/ProcessFlow.tsx @@ -0,0 +1,22 @@ +// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx +import CaseTabsCarousel from "../_components/CaseTabsCarousel"; + + +export default function ProcessFlow() { + return ( +
+
+
+
+

물 재이용 사례

+
+
+
+ + {/* 탭메뉴 영역 */} + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dm_new/app/(landing)/reuse/apply/layout.tsx b/apps/dm_new/app/(landing)/reuse/apply/layout.tsx new file mode 100644 index 000000000..e9e7e5cb0 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/apply/layout.tsx @@ -0,0 +1,13 @@ +// 예) apps/dm/app/(landing)/reuse/apply/layout.tsx +import type { ReactNode } from "react"; +import Lnb from "../_components/Lnb"; +import { LNB_DEMAND } from "../_constants/lnb"; + +export default function ApplyLayout({ children }: { children: ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/dm_new/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx b/apps/dm_new/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx new file mode 100644 index 000000000..877eb7d8c --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx @@ -0,0 +1,99 @@ +// apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx (수질기준참고 모달) +"use client"; + +import { useEffect, useId } from "react"; +import Image from "next/image"; + +type Props = { + open: boolean; + onClose: () => void; + imageSrc: string; + title?: string; +}; + +export default function WaterQualityRefModal({ + open, + onClose, + imageSrc, + title = "수질 기준 참고", +}: Props) { + const titleId = useId(); + + useEffect(() => { + if (!open) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + + // 스크롤 잠금 + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + window.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prev; + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ {/* Backdrop */} + + +

+ {title} +

+ +
+
+ + {/* Body (스크롤 영역) */} +
+
+ {/* 이미지가 길어도 여기서 스크롤 */} + 수질 기준 참고 +
+
+
+ + + ); +} diff --git a/apps/dm_new/app/(landing)/reuse/apply/place/page.tsx b/apps/dm_new/app/(landing)/reuse/apply/place/page.tsx new file mode 100644 index 000000000..2deb0334e --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/apply/place/page.tsx @@ -0,0 +1,350 @@ +// apps/dm/app/(landing)/reuse/apply/place/page.tsx +"use client"; + +import { useMemo, useState } from "react"; +import SubPageHeader from "../../_components/SubPageHeader"; + +type Option = { value: string; label: string }; +type SidoValue = "" | "seoul" | "busan" | "daegu" | "incheon" | "gyeonggi"; + +const SIDO: { value: SidoValue; label: string }[] = [ + { value: "", label: "선택" }, + { value: "seoul", label: "서울특별시" }, + { value: "busan", label: "부산광역시" }, + { value: "daegu", label: "대구광역시" }, + { value: "incheon", label: "인천광역시" }, + { value: "gyeonggi", label: "경기도" }, +]; + +const SIGUNGU_MAP: Record = { + "": [{ value: "", label: "선택" }], + seoul: [ + { value: "", label: "선택" }, + { value: "gangnam", label: "강남구" }, + { value: "yeongdeungpo", label: "영등포구" }, + { value: "mapo", label: "마포구" }, + ], + busan: [ + { value: "", label: "선택" }, + { value: "haeundae", label: "해운대구" }, + { value: "suyeong", label: "수영구" }, + ], + daegu: [ + { value: "", label: "선택" }, + { value: "suseong", label: "수성구" }, + { value: "dalseo", label: "달서구" }, + ], + incheon: [ + { value: "", label: "선택" }, + { value: "namdong", label: "남동구" }, + { value: "yeonsu", label: "연수구" }, + ], + gyeonggi: [ + { value: "", label: "선택" }, + { value: "bundang", label: "성남시 분당구" }, + { value: "suji", label: "용인시 수지구" }, + { value: "ilsandong", label: "고양시 일산동구" }, + ], +}; + +/* --------------------------- + * register/page.tsx 스타일과 맞춘 공통 UI (FormRow/Divider) + * -------------------------- */ + +function Divider() { + return
; +} + +function FormRow({ + label, + required, + children, +}: { + label: React.ReactNode; + required?: boolean; + children: React.ReactNode; +}) { + return ( +
+ {/* 라벨(좌측) */} +
+
+ {label} + {required ? * : null} +
+
+ + {/* 입력(우측) */} +
+ {children} +
+
+ ); +} + +function TextInput({ + value, + onChange, + placeholder, + type = "text", + disabled, + className = "", +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; + disabled?: boolean; + className?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={[ + "h-11 w-full rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800", + "placeholder:text-neutral-400 focus:border-neutral-400 focus:outline-none", + disabled ? "bg-neutral-100 text-neutral-400" : "", + className, + ].join(" ")} + /> + ); +} + +function SelectBox({ + value, + onChange, + options, + className = "", +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string }[]; + className?: string; +}) { + return ( + + ); +} + +export default function DemandPlaceRegisterPage() { + const [sido, setSido] = useState(""); + const [sigungu, setSigungu] = useState(""); + + const [company, setCompany] = useState(""); + const [bizNo, setBizNo] = useState(""); + const [workerNo, setWorkerNo] = useState(""); + const [noWorkerNo, setNoWorkerNo] = useState(false); + + const [zip, setZip] = useState(""); + const [addr1, setAddr1] = useState(""); + const [addr2, setAddr2] = useState(""); + + const [tel, setTel] = useState(""); + const [pw, setPw] = useState(""); + const [pw2, setPw2] = useState(""); + + // ✅ undefined 불가(Record + sido가 SidoValue) + const sigunguOptions = useMemo(() => SIGUNGU_MAP[sido], [sido]); + + const onSubmit = () => { + if (!sido || !sigungu) return alert("시도/시군구를 선택해 주세요."); + if (!company) return alert("회사명을 입력해 주세요."); + if (!bizNo) return alert("사업자등록번호를 입력해 주세요."); + if (!noWorkerNo && !workerNo) + return alert("종사자등록번호를 입력하거나 '해당없음'을 선택해 주세요."); + if (!addr1) return alert("사업자주소를 입력해 주세요."); + if (!tel) return alert("전화번호를 입력해 주세요."); + if (!pw) return alert("비밀번호를 입력해 주세요."); + if (pw !== pw2) return alert("비밀번호가 일치하지 않습니다."); + + alert("수요처 등록(샘플) 완료!"); + }; + + return ( +
+ + + {/* register/page.tsx와 동일한 테이블 룩앤필 */} +
+ + + value={sido} + onChange={(v) => { + setSido(v); + setSigungu(""); // 시도 변경 시 초기화 + }} + options={SIDO} + /> + + + + + + + + + + + +
+ +
+
+ + + + +
+ +
+
+ + + + +
+
+ +
+ + +
+
+ + + + +
+
+
+ +
+ +
+ + + +
+
+ + + + +
+ +
+
+ + + + +
+ +
+
+ + + + +
+ +
+
+
+ + {/* register/page.tsx와 동일하게 하단 중앙 버튼 */} +
+ +
+
+ ); +} diff --git a/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx b/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx new file mode 100644 index 000000000..07a8bf244 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx @@ -0,0 +1,476 @@ +// 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"; + +type PHRange = { min: string; max: string }; + +export default function DemandRegisterPage() { + 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 [reqDate, setReqDate] = useState(today); + const [reqQty, setReqQty] = useState(""); + const [usePurpose, setUsePurpose] = useState(""); + const [hopePrice, setHopePrice] = useState(""); + + // 요구수질 + const [coliform, setColiform] = useState(""); + const [noDetect, setNoDetect] = useState(false); + + const [tn, setTn] = useState(""); + const [residualCl, setResidualCl] = useState(""); + const [tp, setTp] = useState(""); + const [turbidity, setTurbidity] = useState(""); + const [ph, setPh] = useState({ min: "", max: "" }); + const [bod, setBod] = useState(""); + const [chloride, setChloride] = useState(""); + const [odor, setOdor] = useState("불쾌하지 않을 것"); + const [ec, setEc] = useState(""); + const [color, setColor] = useState(""); + const [remark, setRemark] = useState(""); + + // 공급처 후보지 선택(필수아님) + const [cand1, setCand1] = useState(""); + const [cand2, setCand2] = useState(""); + const [cand3, setCand3] = useState(""); + + 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={ +