From 0de9e66988b34eb8e08a3667680a495d991a3c17 Mon Sep 17 00:00:00 2001 From: LGS Date: Tue, 10 Feb 2026 11:06:54 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EB=AF=BC=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EB=AC=BC=EC=9E=AC=EC=9D=B4=EC=9A=A9=20=EB=9E=9C=EB=94=A9=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20(=EC=88=98=EC=9A=94=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(landing)/reuse/_components/Lnb.tsx | 124 +++++ .../reuse/_components/LoginStatus.tsx | 51 ++ .../reuse/_components/SideIndicator.tsx | 99 ++++ .../reuse/_components/SubPageHeader.tsx | 62 +++ .../(landing)/reuse/_components/TopNav.tsx | 465 +++++++++++++++++ apps/dm/app/(landing)/reuse/_constants/lnb.ts | 33 ++ .../(landing)/reuse/_hooks/useScrollSpy.ts | 55 ++ .../app/(landing)/reuse/_sections/Cases.tsx | 77 +++ .../dm/app/(landing)/reuse/_sections/Hero.tsx | 29 ++ .../(landing)/reuse/_sections/IntroCards.tsx | 50 ++ .../(landing)/reuse/_sections/ProcessFlow.tsx | 21 + apps/dm/app/(landing)/reuse/apply/layout.tsx | 13 + .../apply/modal/WaterQualityRefModal.tsx | 99 ++++ .../app/(landing)/reuse/apply/place/page.tsx | 350 +++++++++++++ .../(landing)/reuse/apply/register/page.tsx | 476 ++++++++++++++++++ .../app/(landing)/reuse/apply/status/page.tsx | 338 +++++++++++++ apps/dm/app/(landing)/reuse/layout.tsx | 12 + apps/dm/app/(landing)/reuse/page.tsx | 50 ++ apps/dm/app/(landing)/reuse/reuse.module.css | 238 +++++++++ apps/dm/app/globals.css | 15 +- apps/dm/next.config.ts | 12 + apps/dm/public/images/landing/calendar.svg | 3 + apps/dm/public/images/landing/hero_bg.jpg | Bin 0 -> 6418756 bytes apps/dm/public/images/landing/icon_login.png | Bin 0 -> 353 bytes apps/dm/public/images/landing/icon_logout.png | Bin 0 -> 362 bytes apps/dm/public/images/landing/logo.png | Bin 0 -> 9223 bytes apps/dm/public/images/landing/scroll_down.svg | 4 + .../images/landing/water_quality_ref_1.png | Bin 0 -> 215652 bytes 28 files changed, 2675 insertions(+), 1 deletion(-) create mode 100644 apps/dm/app/(landing)/reuse/_components/Lnb.tsx create mode 100644 apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx create mode 100644 apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx create mode 100644 apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx create mode 100644 apps/dm/app/(landing)/reuse/_components/TopNav.tsx create mode 100644 apps/dm/app/(landing)/reuse/_constants/lnb.ts create mode 100644 apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts create mode 100644 apps/dm/app/(landing)/reuse/_sections/Cases.tsx create mode 100644 apps/dm/app/(landing)/reuse/_sections/Hero.tsx create mode 100644 apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx create mode 100644 apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx create mode 100644 apps/dm/app/(landing)/reuse/apply/layout.tsx create mode 100644 apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx create mode 100644 apps/dm/app/(landing)/reuse/apply/place/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/apply/register/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/apply/status/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/layout.tsx create mode 100644 apps/dm/app/(landing)/reuse/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/reuse.module.css create mode 100644 apps/dm/public/images/landing/calendar.svg create mode 100644 apps/dm/public/images/landing/hero_bg.jpg create mode 100644 apps/dm/public/images/landing/icon_login.png create mode 100644 apps/dm/public/images/landing/icon_logout.png create mode 100644 apps/dm/public/images/landing/logo.png create mode 100644 apps/dm/public/images/landing/scroll_down.svg create mode 100644 apps/dm/public/images/landing/water_quality_ref_1.png diff --git a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx b/apps/dm/app/(landing)/reuse/_components/Lnb.tsx new file mode 100644 index 000000000..e19b60d0b --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_components/Lnb.tsx @@ -0,0 +1,124 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export type LnbItem = { + label: string; + href?: string; // ✅ href 없는 부모(2뎁스 컨테이너)도 허용 + children?: LnbItem[]; +}; + +function isExactActive(pathname: string, href?: string) { + if (!href) return false; + return pathname === href; // ✅ exact match만 허용 (중요!) +} + +export default function Lnb({ title, items }: { title: string; items: LnbItem[] }) { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx b/apps/dm/app/(landing)/reuse/_components/LoginStatus.tsx new file mode 100644 index 000000000..4ddc08812 --- /dev/null +++ b/apps/dm/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/app/(landing)/reuse/_components/SideIndicator.tsx b/apps/dm/app/(landing)/reuse/_components/SideIndicator.tsx new file mode 100644 index 000000000..8f4cea164 --- /dev/null +++ b/apps/dm/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/app/(landing)/reuse/_components/SubPageHeader.tsx b/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx new file mode 100644 index 000000000..2b55a5cfd --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_components/SubPageHeader.tsx @@ -0,0 +1,62 @@ +"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; + hideFooterLine?: boolean; + hideGuideText?: boolean; +}; + +export default function SubPageHeader({ + title, + crumbs, + note = "* 는 필수 값입니다.", + underlineWidthClass = "w-40", + className = "", + 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 new file mode 100644 index 000000000..1e45825c6 --- /dev/null +++ b/apps/dm/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", label: "재이용소개" }, + { href: "/reuse/cases", label: "재이용사례" }, + { href: "/reuse/business", 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 new file mode 100644 index 000000000..f9273d24a --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_constants/lnb.ts @@ -0,0 +1,33 @@ +// 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: "수요신청현황" }, +]; + +// (예시) 재이용사례처럼 2depth가 있는 메뉴 +export const LNB_CASES: LnbItem[] = [ + { + label: "빗물 재이용 사례", + children: [ + { label: "경기도 파주시", href: "/reuse/cases/rain/paju" }, + { label: "경기도 수원시", href: "/reuse/cases/rain/suwon" }, + { label: "충청남도 보령시", href: "/reuse/cases/rain/boryeong" }, + { label: "충청북도 청주시", href: "/reuse/cases/rain/cheongju" }, + ], + }, + { + label: "중수 재이용 사례", + children: [{ label: "사례 목록", href: "/reuse/cases/graywater" }], + }, + { + label: "하수 재이용 사례", + children: [{ label: "사례 목록", href: "/reuse/cases/sewage" }], + }, + { + label: "해외 재이용 사례", + children: [{ label: "사례 목록", href: "/reuse/cases/global" }], + }, +]; diff --git a/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts b/apps/dm/app/(landing)/reuse/_hooks/useScrollSpy.ts new file mode 100644 index 000000000..3a253e95d --- /dev/null +++ b/apps/dm/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/app/(landing)/reuse/_sections/Cases.tsx b/apps/dm/app/(landing)/reuse/_sections/Cases.tsx new file mode 100644 index 000000000..0014cecaa --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_sections/Cases.tsx @@ -0,0 +1,77 @@ +// apps/dm/app/(landing)/reuse/_sections/Cases.tsx +"use client"; + +import { useMemo, useState } from "react"; + +type Tab = { + key: string; + label: string; + img: string; +}; + +const tabs: readonly [Tab, ...Tab[]] = [ + { key: "a", label: "경기권역 반도체 사업장 물 재이용", img: "/landing/reuse/case_diagram_01.png" }, + { key: "b", label: "경기도 파주시 하수재이용", img: "/landing/reuse/case_diagram_01.png" }, + { key: "c", label: "충남 보령 하수재이용", img: "/landing/reuse/case_diagram_01.png" }, + { key: "d", label: "수원시 빗물이용과 중수도", img: "/landing/reuse/case_diagram_01.png" }, +]; + +export default function Cases() { + const [active, setActive] = useState(() => tabs[0]?.key ?? ""); + const current = tabs.find((t) => t.key === active) ?? tabs[0]; + + if (!current) { + return null; // 또는 로딩/빈상태 UI + } + + return ( +
+
+
+

재이용사례

+
+
+ +
+
+ {tabs.map((t) => { + const on = t.key === active; + 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 new file mode 100644 index 000000000..4029a5c51 --- /dev/null +++ b/apps/dm/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/app/(landing)/reuse/_sections/IntroCards.tsx b/apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx new file mode 100644 index 000000000..dfcbdf8f5 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx @@ -0,0 +1,50 @@ + +// apps/dm/app/(landing)/reuse/_sections/IntroCards.tsx +const cards = [ + { title: "친환경 수자원 확보", img: "/landing/reuse/card_01.jpg" }, + { title: "알뜰하게 재이용하는 중수", img: "/landing/reuse/card_02.jpg" }, + { title: "하수처리수 재이용", img: "/landing/reuse/card_03.jpg" }, + { title: "2030년 물 재이용 목표", img: "/landing/reuse/card_04.jpg" }, +]; + +export default function IntroCards() { + return ( +
+
+
+

재이용소개

+
+

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

+
+ +
+ {cards.map((c) => ( +
+ +
+
+

{c.title}

+ + {/* 예: 첫 카드에만 다운로드 버튼 */} + {c.title === "친환경 수자원 확보" && ( + + )} +
+
+ ))} +
+
+
+ ); +} \ 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 new file mode 100644 index 000000000..8f9067f0e --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx @@ -0,0 +1,21 @@ +// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx +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 new file mode 100644 index 000000000..e9e7e5cb0 --- /dev/null +++ b/apps/dm/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/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx b/apps/dm/app/(landing)/reuse/apply/modal/WaterQualityRefModal.tsx new file mode 100644 index 000000000..877eb7d8c --- /dev/null +++ b/apps/dm/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/app/(landing)/reuse/apply/place/page.tsx b/apps/dm/app/(landing)/reuse/apply/place/page.tsx new file mode 100644 index 000000000..2deb0334e --- /dev/null +++ b/apps/dm/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/app/(landing)/reuse/apply/register/page.tsx b/apps/dm/app/(landing)/reuse/apply/register/page.tsx new file mode 100644 index 000000000..07a8bf244 --- /dev/null +++ b/apps/dm/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={ +