diff --git a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx deleted file mode 100644 index 44f790366..000000000 --- a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx +++ /dev/null @@ -1,540 +0,0 @@ -"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: "/reuse/business/sewage/paju", - }, - { - id: "sewage-2", - title: "충남보령 하수재이용", - desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...", - imageSrc: "/images/landing/sewage_02.png", - href: "/reuse/business/sewage/bolyeong", - }, - { - id: "sewage-3", - title: "여수시 하수처리수 재이용", - desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...", - imageSrc: "/images/landing/sewage_03.png", - href: "/reuse/business/sewage/yeosu", - }, - { - id: "sewage-4", - title: "청주시 하수 재이용 시설", - desc: "청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO) 청주시 하수처리수 재이용 민간투자사업(BTO)...", - imageSrc: "/images/landing/sewage_04.png", - href: "/reuse/business/sewage/cheongju", - }, - { - id: "sewage-5", - title: "수원시 황구지천 하천유지용수", - desc: "황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로...", - imageSrc: "/images/landing/sewage_05.png", - href: "/reuse/business/sewage/hwanggu", - }, - { - id: "sewage-6", - title: "구리시 도로자동청소시스템", - desc: "고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용...", - imageSrc: "/images/landing/sewage_06.png", - href: "/reuse/business/sewage/guri", - }, - ...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/app/(landing)/reuse/_components/ImageSlider.tsx b/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx deleted file mode 100644 index 6c50d5a5c..000000000 --- a/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// 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/app/(landing)/reuse/_components/Lnb.tsx b/apps/dm/app/(landing)/reuse/_components/Lnb.tsx deleted file mode 100644 index c7d834844..000000000 --- a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// 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/app/(landing)/reuse/_components/LoginStatus.tsx b/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx deleted file mode 100644 index 4ddc08812..000000000 --- a/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx +++ /dev/null @@ -1,51 +0,0 @@ -// 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/app/(landing)/reuse/_components/SideIndicator.tsx b/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx deleted file mode 100644 index 8f4cea164..000000000 --- a/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"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/app/(landing)/reuse/_components/SubPageHeader.tsx b/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx deleted file mode 100644 index fc3bade23..000000000 --- a/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"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/app/(landing)/reuse/_components/TopNav.tsx b/apps/dm/app/(landing)/reuse/_components/TopNav.tsx deleted file mode 100644 index 078d5e0d4..000000000 --- a/apps/dm/app/(landing)/reuse/_components/TopNav.tsx +++ /dev/null @@ -1,465 +0,0 @@ -// 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/app/(landing)/reuse/_constants/lnb.ts b/apps/dm/app/(landing)/reuse/_constants/lnb.ts deleted file mode 100644 index 49c9ae1ae..000000000 --- a/apps/dm/app/(landing)/reuse/_constants/lnb.ts +++ /dev/null @@ -1,58 +0,0 @@ -// 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/globalCase/overview" }, - { label: "대만", href: "/reuse/business/globalCase/taiwan" }, - { label: "호주", href: "/reuse/business/globalCase/australia" }, - { label: "싱가포르", href: "/reuse/business/globalCase/singapore" }, - { label: "EU", href: "/reuse/business/globalCase/eu" } - ], - }, -]; diff --git a/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts b/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts deleted file mode 100644 index 3a253e95d..000000000 --- a/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts +++ /dev/null @@ -1,55 +0,0 @@ -"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/app/(landing)/reuse/_sections/Cases.tsx b/apps/dm/app/(landing)/reuse/_sections/Cases.tsx deleted file mode 100644 index 077d05cfe..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/Cases.tsx +++ /dev/null @@ -1,65 +0,0 @@ -// 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/app/(landing)/reuse/_sections/Hero.tsx b/apps/dm/app/(landing)/reuse/_sections/Hero.tsx deleted file mode 100644 index 8f93bdd72..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/Hero.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// 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/app/(landing)/reuse/_sections/IntroCards.tsx b/apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx deleted file mode 100644 index 070d9ba91..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// 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/app/(landing)/reuse/_sections/ProcessFlow.tsx b/apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx deleted file mode 100644 index 761a79201..000000000 --- a/apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// 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/app/(landing)/reuse/apply/layout.tsx b/apps/dm/app/(landing)/reuse/apply/layout.tsx deleted file mode 100644 index e9e7e5cb0..000000000 --- a/apps/dm/app/(landing)/reuse/apply/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -// 예) 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/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx b/apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx deleted file mode 100644 index 877eb7d8c..000000000 --- a/apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// 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/app/(landing)/reuse/apply/place/page.tsx b/apps/dm/app/(landing)/reuse/apply/place/page.tsx deleted file mode 100644 index 2deb0334e..000000000 --- a/apps/dm/app/(landing)/reuse/apply/place/page.tsx +++ /dev/null @@ -1,350 +0,0 @@ -// 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/app/(landing)/reuse/apply/register/page.tsx b/apps/dm/app/(landing)/reuse/apply/register/page.tsx deleted file mode 100644 index 07a8bf244..000000000 --- a/apps/dm/app/(landing)/reuse/apply/register/page.tsx +++ /dev/null @@ -1,476 +0,0 @@ -// 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={ -