Compare commits

...
Author SHA1 Message Date
mind-one005 cd392b0f11 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-02-23 10:01:57 +09:00
mind-one005 019bfaf9c2 dm_new merge 2026-02-23 10:01:49 +09:00
mind-one005 a0e9d5dcab Merge branch 'iipbs/dm/dev' into iipbs/sn/dev 2026-02-23 09:46:25 +09:00
mind-one005 acdeb9af6e dm_new: topNav 로그인 상태 수정 2026-02-23 09:42:32 +09:00
mind-one005 46c1d33750 dm_new: 수요처등록수정 2026-02-20 17:31:46 +09:00
mind-one005 b0baff824b Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2026-02-20 16:48:39 +09:00
mind-one005 baa42bd669 dm_new: 디자인 수정 2026-02-20 16:48:34 +09:00
LGS cec93cf77e 대민서비스 물재이용 랜딩 페이지 수요정보등록 페이지 요구시기 데이트피커 달력 아이콘 svg 속성 오류 수정 2026-02-20 16:43:57 +09:00
LGS a154a11ed0 대민서비스 물재이용 랜딩 페이지 서브 타이틀 라인 UI 수정 및 기타 수정 2026-02-20 16:04:17 +09:00
LGS 76fdba2bc9 대민서비스 물재이용 랜딩 페이지 최소 해상도 1800 사이즈 고정처리 2026-02-20 11:36:18 +09:00
dongsilove 17a5c20266 dm_new: 로그인 처리 중 2026-02-19 23:47:56 +09:00
mind-one005 d55b7e384e Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2026-02-19 18:12:39 +09:00
mind-one005 f4de1a6027 dm_new: 기타 수정 2026-02-19 18:12:34 +09:00
LGS 92ed4fccc8 대민서비스 물재이용 랜딩 페이지(물재이용사례 - 해외재이용사례 서브페이지 추가) 2026-02-19 17:02:50 +09:00
mind-one005 640d4cb1a8 dm_new: Lnb 제목 외 2026-02-19 14:54:30 +09:00
LGS 8826340780 대민서비스 물재이용 랜딩 페이지(물재이용사례 - 사례개요 및 메인화면 "바로가기" 서브페이지 라우팅 연결) 2026-02-19 10:33:05 +09:00
LGS b67f4828b1 Merge commit '7a91fcbd8042d4b8518583a1687a5857a4ded163' into iipbs/dm/dev 2026-02-19 09:32:19 +09:00
dongsilove 7a91fcbd80 dm_new menu 2026-02-18 22:11:12 +09:00
dongsilove f621f4dfa1 Merge remote-tracking branch 'origin/main' into iipbs/dm/dev 2026-02-14 15:21:57 +09:00
dongsilove 5dc6fe4e3b wr: 엑셀 버튼명 2026-02-13 22:32:24 +09:00
dongsilove f2a71893d4 wr: 항목 표출 변경 2026-02-13 21:17:17 +09:00
dongsilove a91bcd9cf7 wr: 시군구 초기화 2026-02-13 20:21:06 +09:00
mind-one005 d9baf33a63 dm_new 2026-02-13 16:43:19 +09:00
dongsilove 62fef6bcdc wr: 버튼 크기 통일 2026-02-12 23:48:44 +09:00
dongsilove 60edf0a1ff wr: 시설등록, 실적등록 버튼 수정 2026-02-12 23:02:46 +09:00
mind-one005 36ce09b754 dm_new 추가 2026-02-12 18:18:08 +09:00
LGS d4775ff745 대민서비스 물재이용 랜딩 페이지(수요신청 외 서브페이지 추가) 2026-02-12 17:41:36 +09:00
dongsilove ac598c7215 wr: 하수재이용시설등록 버튼 2026-02-11 22:46:54 +09:00
dongsilove 321911371a wr: (map-layout) layout 수정 2026-02-11 22:07:00 +09:00
mind-one005 7e82714678 메뉴와 화면제목 일치 wr, fo 2026-02-11 18:17:39 +09:00
mind-one005 fa6f2fdcd7 fo: exceldown , 추가 -수질,계획하수량 2026-02-11 16:58:52 +09:00
LGS 0de9e66988 대민서비스 물재이용 랜딩 페이지 (수요신청 페이지 추가) 2026-02-10 11:06:54 +09:00
259 changed files with 17474 additions and 727 deletions
+26
View File
@@ -0,0 +1,26 @@
NEXT_PUBLIC_SITE_ID = DN
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=
+24
View File
@@ -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=
+24
View File
@@ -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=
@@ -0,0 +1,570 @@
"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<TabKey, CaseItem[]> = {
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: [
{
id: "global-1",
title: "해외 재이용 사례 개요",
desc: "2024년 하수도 통계 기준 15.6%이며, 제2차 물 재이용 기본 계획을 고려한 2030년 목표는 17%, 우리나라의 하수 처리 재이용률은 15% 수준...",
imageSrc: "/images/landing/overview_01.png",
href: "reuse/business/globalCase/overview",
},
{
id: "global-2",
title: "[대만] 국가 주도의 공업용수 공급",
desc: "대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑), 안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능...",
imageSrc: "/images/landing/taiwan_01.png",
href: "/reuse/business/globalCase/taiwan",
},
{
id: "global-3",
title: "[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급",
desc: "SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망, 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급...",
imageSrc: "/images/landing/australia_01.png",
href: "/reuse/business/globalCase/australia",
},
{
id: "global-4",
title: "[싱가포르] 수요가 높은 산업의 물 재이용 의무화",
desc: "2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설, NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용...",
imageSrc: "/images/landing/singapore_01.png",
href: "/reuse/business/globalCase/singapore",
},
{
id: "global-5",
title: "[EU] 법률을 제정, EU국가에서 물 재이용 유도",
desc: "유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741), 2023년부터 모든 EU국가에서 시행...",
imageSrc: "/images/landing/eu_01.png",
href: "/reuse/business/globalCase/eu",
},
],
};
/* ---------------- utils ---------------- */
function chunk<T>(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<HTMLDivElement | null>(null);
const clipRef = useRef<HTMLDivElement | null>(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<TabKey>("rain");
// 탭별 마지막 그룹 인덱스 기억
const tabIndexRef = useRef<Record<TabKey, number>>({
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<HTMLDivElement>) => {
// 자식 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 (
<section className="w-full">
{/* 탭 바 */}
<div className="mx-auto max-w-[1520px] pt-10">
<div className="">
<div className="grid grid-cols-4">
{TABS.map((t) => {
const isActive = t.key === active;
return (
<button
key={t.key}
type="button"
onClick={() => setActive(t.key)}
className={[
"relative h-[74px] w-full text-base font-semibold",
"transition-colors",
// 기본(비활성): 검정 배경 + 회색 텍스트
!isActive ? "text-[#767676] hover:text-black border-b-2 border-sky-400" : "",
// 활성: 흰 배경 + 진한 텍스트 + 하늘색 테두리(위/좌/우) + 아래는 열어둠
isActive
? [
"bg-[#eaf4f4] text-black",
"border-t-2 border-l-2 border-r-2 border-sky-400",
"border-b-0", // 아래는 컨텐츠와 이어지도록 열어둠
"z-10", // 테두리가 위로 올라오게
].join(" ")
: "",
].join(" ")}
>
{t.label}
{/* 활성 탭 하단에 얇은 라인이 필요하면(이미지처럼) */}
{isActive && (
<span className="pointer-events-none absolute left-0 right-0 bottom-[-1px] h-[2px] bg-[#eaf4f4]" />
)}
</button>
);
})}
</div>
</div>
</div>
{/* 슬라이더 */}
<div ref={wrapRef} className="mx-auto max-w-[1520px] px-6 pb-5 pt-10 bg-[#eaf4f4]">
<div className="relative">
{/* 좌우 버튼 */}
<button
type="button"
onClick={() => go(-1)}
disabled={!canSlide || busy}
className={[
"absolute left-0 top-1/2 z-10 -translate-y-1/2",
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
"grid place-items-center border-2 border-[#C2CAD2]",
!canSlide || busy
? "opacity-40 cursor-not-allowed"
: "hover:brightness-95",
].join(" ")}
aria-label="이전"
>
<img
src="/images/landing/icon_left_arrow.png"
alt="이전"
className="w-6 h-6 object-contain"
/>
</button>
<button
type="button"
onClick={() => go(1)}
disabled={!canSlide || busy}
className={[
"absolute right-0 top-1/2 z-10 -translate-y-1/2",
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
"grid place-items-center border-2 border-[#C2CAD2]",
!canSlide || busy
? "opacity-40 cursor-not-allowed"
: "hover:brightness-95",
].join(" ")}
aria-label="다음"
>
<img
src="/images/landing/icon_right_arrow.png"
alt="다음"
className="w-6 h-6 object-contain"
/>
</button>
{/* 중요: clip(가림)과 measure(측정) 분리 */}
<div className="overflow-hidden [clip-path:inset(0)] [contain:paint]">
<div className="w-[1250px] overflow-hidden m-auto">
{/* 측정은 패딩 없는 박스에서 */}
<div ref={clipRef} className="w-full">
{/* track */}
<div className="flex" style={trackStyle} onTransitionEnd={onTransitionEnd}>
{viewGroups.map((group, gi) => (
<div
key={`pane-${gi}`}
className="shrink-0"
style={{ width: clipWidth || "100%" }}
>
{/* 그룹 내부: perPage 컬럼 */}
<div
className="grid gap-7"
style={{
gridTemplateColumns: `repeat(${perPage}, minmax(0, 1fr))`,
}}
>
{(group ?? []).map((c) => (
<article
key={c.id}
className="overflow-hidden rounded-[22px] border-2 border-sky-400 bg-white"
>
<div className="h-[241px] w-full bg-slate-100 border-b border-b-blue-300">
<img
src={c.imageSrc}
alt={c.title}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
<div className="p-8">
<h3 className="h-[77px] text-center text-[28px] font-extrabold leading-snug tracking-tight">
{c.title}
</h3>
<p className="mt-4 line-clamp-3 text-[16px] leading-7 text-slate-600">
{c.desc}
</p>
<a
href={c.href ?? "#"}
className="mt-8 inline-flex h-[56px] w-full items-center justify-center gap-3 rounded-md bg-[#02A0D6] text-[18px] font-bold text-white transition hover:brightness-110"
>
자세히 보기 <span aria-hidden>→</span>
</a>
</div>
</article>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* 도트 */}
<div className="mt-8 flex items-center justify-center gap-3">
{Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => (
<button
key={`dot-${i}`}
type="button"
onClick={() => goDot(i)}
className={[
"h-[10px] w-[10px] rounded-full transition",
i === cur
? "bg-black opacity-100"
: "bg-black opacity-30 hover:opacity-60",
].join(" ")}
aria-label={`${i + 1}번 그룹`}
/>
))}
</div>
</div>
</div>
</section>
);
}
@@ -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 (
<section className={className}>
<div className={`mx-auto ${maxWidthClassName}`}>
<div className="relative">
{/* 좌측 버튼 */}
<button
type="button"
onClick={() => go(-1)}
className="absolute left-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_left_arrow.png"
alt="이전"
className="w-[12px] h-[22px]"
/>
</button>
{/* 우측 버튼 */}
<button
type="button"
onClick={() => go(1)}
className="absolute right-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_right_arrow.png"
alt="다음"
className="w-[12px] h-[22px]"
/>
</button>
{/* 뷰포트 */}
<div
className={[
"overflow-hidden",
"select-none touch-pan-y",
"cursor-grab active:cursor-grabbing",
"w-[583px] h-[400px] mx-auto",
].join(" ")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className="flex transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${idx * 100}%)` }}
>
{safeSlides.map((s, i) => (
<div key={`${s.src}-${i}`} className="min-w-full">
<img
src={s.src}
alt={s.alt ?? `slide-${i + 1}`}
className="w-full h-full object-contain"
draggable={false}
/>
</div>
))}
</div>
</div>
</div>
{/* 도트 */}
{canSlide && (
<div className="mt-3 flex items-center justify-center gap-2">
{safeSlides.map((_, i) => {
const active = i === idx;
return (
<button
key={i}
type="button"
onClick={() => setIdx(i)}
aria-label={`${i + 1}번 슬라이드`}
className={[
"h-2 w-2 rounded-full",
active ? "bg-black/70" : "bg-black/20 hover:bg-black/35",
].join(" ")}
/>
);
})}
</div>
)}
</div>
</section>
);
}
@@ -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<string | null>(defaultOpenLabel);
return (
<aside className="w-[260px] min-w-[260px] shrink-0">
{/* 타이틀 */}
<div
className="h-[120px] flex items-center justify-center
text-white text-[40px] font-bold
bg-gradient-to-r from-cyan-500 to-green-500"
>
{title}
</div>
{/* 메뉴 */}
<ul>
{items.map((item) => {
const hasChildren = !!item.children?.length;
// 1뎁스 active
const active1 = isExactActive(pathname, item.href);
// 2뎁스 active(자식 중 exact)
const active2 = item.children?.some((c) => isExactActive(pathname, c.href)) ?? false;
const active = active1 || active2;
// ✅ children 있는 경우: 아코디언 헤더 + 패널
if (hasChildren && !item.href) {
const isOpen = openLabel === item.label;
return (
<li key={item.label}>
{/* 아코디언 헤더 */}
<button
type="button"
onClick={() => setOpenLabel((prev) => (prev === item.label ? null : item.label))}
className={[
"w-full px-5 py-6",
"flex items-center justify-between",
"border-b border-slate-300",
"text-center text-base font-semibold",
active ? "bg-slate-100 text-slate-900" : "bg-slate-100 text-slate-500",
].join(" ")}
aria-expanded={isOpen}
>
<span className="w-full text-center">{item.label}</span>
{/* ✅ 우측 화살표(이미지처럼) */}
<span
aria-hidden
className={[
"ml-3 shrink-0 transition-transform duration-200",
isOpen ? "rotate-180" : "rotate-0",
].join(" ")}
>
<img
src="/images/landing/drop_arrow.svg"
alt="드롭다운 화살표 아이콘"
className="w-7 h-7"
/>
</span>
</button>
{/* 패널: height auto 애니메이션(그리드 트릭) */}
<div
className={[
"grid transition-[grid-template-rows] duration-200 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
].join(" ")}
>
<div className="overflow-hidden bg-white">
<ul>
{item.children!.map((child) => {
const childActive = isExactActive(pathname, child.href);
return (
<li key={child.href ?? child.label}>
{child.href ? (
<Link
href={child.href}
className={[
"block px-5 py-4 pl-[60px] text-[18px] font-medium",
"border-b border-slate-200 transition-colors",
childActive
? "bg-white text-sky-500 cursor-default"
: "bg-slate-50 text-slate-500 hover:bg-white hover:text-sky-500",
].join(" ")}
>
{/* 이미지처럼 불릿 필요하면 */}
<span className="mr-2 text-current">
•
</span>
{child.label}
</Link>
) : (
<div className="block px-5 py-4 text-[18px] font-medium border-b border-slate-200 bg-slate-50 text-slate-500">
{child.label}
</div>
)}
</li>
);
})}
</ul>
</div>
</div>
</li>
);
}
// ✅ 일반 1뎁스 메뉴(기존 유지)
return (
<li key={item.href ?? item.label}>
{item.href ? (
<Link
href={item.href}
className={[
"block px-5 py-4 text-center text-base font-semibold",
"border-b border-slate-300 transition-colors",
active
? "bg-white text-sky-500 cursor-default"
: "bg-[#F0F0F0] text-[#767676] hover:bg-white hover:text-sky-500",
].join(" ")}
>
{item.label}
</Link>
) : (
<div
className={[
"block px-5 py-4 text-center text-base font-semibold",
"border-b border-slate-300",
"bg-slate-100 text-slate-500",
].join(" ")}
>
{item.label}
</div>
)}
</li>
);
})}
</ul>
</aside>
);
}
@@ -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<string | null>(null);
useEffect(() => {
// 예시: 로그인 시 저장한 값
const name = localStorage.getItem("userName");
setUserName(name);
}, []);
const handleLogout = () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("userName");
router.refresh(); // 헤더 상태 갱신
};
// 🔹 로그인 전
if (!userName) {
return (
<Link
href="/reuse/login"
className="flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600"
>
로그인
<span>→</span>
</Link>
);
}
// 🔹 로그인 후
return (
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-700">
<strong>{userName}</strong>님 환영합니다.
</span>
<button
onClick={handleLogout}
className="rounded-md bg-red-500 px-3 py-2 text-white hover:bg-red-600"
>
로그아웃
</button>
</div>
);
}
@@ -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<string | null>(null);
const clearTimerRef = useRef<number | null>(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 (
<aside className={styles.sideIndicator} aria-label="섹션 이동">
<div className={styles.sideIndicatorLine} aria-hidden="true" />
<ul className={styles.sideIndicatorList}>
{items.map((it) => {
const isActive = it.id === currentId;
const isMain = it.id === "main";
const displayLabel = it.sideLabel ?? it.label;
return (
<li key={it.id} className={styles.sideIndicatorItem}>
<button
type="button"
onClick={() => scrollTo(it.id)}
className={[
styles.indicatorBtn,
isActive ? styles.indicatorActive : styles.indicatorDot,
isActive
? isMain
? styles.indicatorMainActive
: styles.indicatorSectionActive
: "",
].join(" ")}
aria-current={isActive ? "location" : undefined}
aria-label={displayLabel}
>
{/* ✅ 텍스트 중복 렌더링 제거:
- active일 때만 시각적 텍스트 표시
- 비활성은 SR-only로만 제공 (CSS에 따라 필요 없으면 제거 가능) */}
{isActive ? (
<span className={styles.indicatorText}>{displayLabel}</span>
) : (
<span className="sr-only">{displayLabel}</span>
)}
</button>
</li>
);
})}
</ul>
</aside>
);
}
@@ -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 (
<section className={`text-black ${className}`}>
<div className={`pb-5 ${wrapClassName}`}>
<div className="flex items-start justify-between gap-6">
{/* 좌측 타이틀 */}
<div className="inline-block">
<h1 className="relative z-10 px-2 text-[28px] font-extrabold tracking-tight text-slate-900 leading-none">{title}</h1>
<div className="relative top-[-7px] -z-1 h-4 bg-gradient-to-r from-sky-300 to-green-300" />
</div>
{/* 우측 브레드크럼 */}
<nav className="pt-2 text-sm text-black/70" aria-label="breadcrumb">
{crumbs.map((c, i) => (
<span key={`${c.label}-${i}`}>
{c.href ? (
<Link href={c.href} className="hover:text-sky-500">
{c.label}
</Link>
) : (
<span className="text-black/90">{c.label}</span>
)}
{i < crumbs.length - 1 && <span className="mx-2">›</span>}
</span>
))}
</nav>
</div>
{/* 하단 라인 + 안내문 */}
{!hideGuideText && (
<div className="mt-6 border-t border-black/10 pt-4 text-sm text-black/70">
<span className="text-red-400">*</span>
{note.replace("*", "")}
</div>
)}
</div>
</section>
);
}
@@ -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<Partial<Record<FieldKey, string>>>({});
const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
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<Record<FieldKey, string>> = {};
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 (
<div className="fixed inset-0 z-[100]">
{/* Backdrop */}
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>
{/* Dialog */}
<div className="absolute inset-0 flex items-center justify-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-[600px] rounded-[16px] bg-white shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="relative px-10 pt-10">
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute right-8 top-8 z-40 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
>
<span className="text-2xl leading-none">×</span>
</button>
<h2
id={titleId}
className="relative z-10 px-2 text-center text-[32px] font-bold tracking-tight leading-none text-gray-900"
>
수요처 로그인
</h2>
{/* title underline gradient bar */}
<div className="relative top-[-7px] -z-1 mx-auto h-[10px] w-[240px] bg-gradient-to-r from-sky-300 to-green-300" />
</div>
{/* Body */}
<form onSubmit={submit} className="px-14 pb-12 pt-10">
<div className="space-y-7">
{/* 사업자 등록번호 */}
<div>
<label className="block text-[18px] font-bold text-gray-900 mb-3">
사업자 등록번호
</label>
<input
value={bizNo}
onChange={(e) => {
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 && (
<p className="mt-3 text-[14px] font-medium text-red-500">
{errors.bizNo}
</p>
)}
</div>
{/* 종사자등록번호 + 해당없음 */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-[18px] font-bold text-gray-900">
종사자등록번호
</label>
<label className="flex items-center gap-1 text-[16px] text-[#4F4F4F] select-none">
<input
type="checkbox"
checked={noWorker}
onChange={(e) => setNoWorker(e.target.checked)}
className="h-4 w-4 accent-[#004A94]"
/>
해당없음
</label>
</div>
<input
value={workerNo}
onChange={(e) => {
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 && (
<p className="mt-3 text-[14px] font-medium text-red-500">
{errors.workerNo}
</p>
)}
</div>
{/* 비밀번호 */}
<div>
<label className="block text-[18px] font-bold text-gray-900 mb-3">
비밀번호
</label>
<input
type="password"
value={pw}
onChange={(e) => {
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 && (
<p className="mt-3 text-[14px] font-medium text-red-500">
{errors.pw}
</p>
)}
</div>
{/* 로그인 버튼 */}
<button
type="submit"
disabled={submitting}
className={[
"mt-2 w-full h-[60px] rounded-[4px] text-white text-[18px]",
submitting
? "bg-sky-300 cursor-not-allowed"
: "bg-sky-500 hover:bg-sky-600 active:bg-sky-700",
].join(" ")}
>
{submitting ? "로그인 중..." : "로그인"}
</button>
{/* 비밀번호 초기화 요청 */}
<button
type="button"
onClick={() => alert("TODO: 비밀번호 초기화 요청 화면/기능 연결")}
className="mx-auto block text-[14px] text-[#767676] hover:text-gray-900"
>
비밀번호 초기화 요청
</button>
{/* 하단 안내 박스 + 수요처등록 버튼 */}
<div className="mt-6 border border-[#004A94] bg-[#EEF3F9] px-4 py-4 flex items-center justify-between gap-6">
<div className="leading-snug">
<div className="text-[16px] text-[#8698AA]">
수요처를 등록하지 않았다면
</div>
<div className="text-[18px] text-[#004A94]">
먼저 수요처등록을 해주세요.
</div>
</div>
<button
type="button"
onClick={() => router.push(REGISTER_HREF)}
className="shrink-0 rounded-[4px] h-[48px] px-10 bg-[#004A94] text-white text-[18px] hover:opacity-95"
>
수요처등록
</button>
</div>
</div>
</form>
</div>
</div>
</div>
);
}
export default function TopNav() {
const router = useRouter();
const [loginOpen, setLoginOpen] = useState(false);
const [userName, setUserName] = useState<string | null>(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 (
<>
<header className="fixed top-0 left-0 right-0 z-50 bg-white backdrop-blur border-b shadow-md min-w-[1800px]">
{/* ✅ 좌/중/우 정렬이 되도록 컨테이너 구성 */}
<div className="px-6">
<div className="relative h-[83px] flex items-center justify-between">
{/* 좌측: 로고 */}
<div className="flex items-center gap-3">
<Link href="/reuse" className="flex items-center">
<img
src="/images/landing/logo.png"
alt="HASUON"
className="h-9 w-auto"
/>
</Link>
</div>
{/* 중앙: 상단 메뉴 */}
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-[40px] text-sm font-medium">
{NAV_ITEMS.map((it) => {
// ✅ 수요신청: 로그인 전이면 로그인 모달, 로그인 후면 페이지 이동
if (it.label === "수요신청") {
return (
<button
key={it.href}
type="button"
onClick={() => {
if (!userName) openLogin();
else router.push(it.href);
}}
className={[
"relative px-3 py-2 text-lg font-bold",
"text-gray-800 hover:text-sky-700",
].join(" ")}
>
{it.label}
</button>
);
}
return (
<Link
key={it.href}
href={it.href}
className={[
"relative px-3 py-2 text-lg font-bold",
"text-gray-800 hover:text-sky-700",
].join(" ")}
>
{it.label}
</Link>
);
})}
</nav>
{/* ✅ 우측: 로그인 버튼 / 로그인 정보 + 로그아웃 */}
<div className="flex items-center gap-3">
{!userName ? (
<button
type="button"
onClick={openLogin}
className="inline-flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-base text-white hover:bg-sky-600"
>
로그인
<span aria-hidden>
<img src="/images/landing/icon_login.png" alt="로그인 아이콘" className="h-6 w-6 object-contain ml-1"/>
</span>
</button>
) : (
<>
<span className="text-base text-gray-700">
<strong className="text-xl text-emerald-600">{userName}</strong>님 환영합니다.
</span>
<button
type="button"
onClick={logout}
className="inline-flex items-center gap-2 rounded-md bg-red-500 px-4 py-2 text-base text-white hover:bg-red-600"
>
로그아웃
<span aria-hidden>
<img src="/images/landing/icon_logout.png" alt="로그아웃 아이콘" className="h-6 w-6 object-contain"/>
</span>
</button>
</>
)}
</div>
</div>
</div>
</header>
{/* ✅ 로그인 모달 */}
<DemandLoginModal
open={loginOpen}
onClose={() => 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();
}}
/>
</>
);
}
@@ -0,0 +1,58 @@
// 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" }
],
},
];
@@ -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<Map<string, number>>(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;
}
@@ -0,0 +1,65 @@
// apps/dm/app/(landing)/reuse/_sections/Cases.tsx
"use client";
export default function Cases() {
return (
<section id="cases" className="bg-[#EAF6F6]">
<div className="mx-auto max-w-[1520px] px-6 h-[870px] flex items-center">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full">
{/* 좌측 : 카피 영역 */}
<div className="flex flex-col justify-center">
<div className="pl-2 lg:pl-10">
<p className="text-[32px] font-extrabold text-slate-900 tracking-[-0.02em]">
빗물+중수도+하수도
</p>
<h2 className="mt-6 leading-[1.1] tracking-[-0.03em]">
<span className="block text-[64px] font-extrabold text-slate-900">
데이터로 보는
</span>
<span className="mt-3 block text-[64px] font-extrabold text-[#004A94]">
물 재이용 현황
</span>
</h2>
<div className="mt-14">
<a
href="/reuse/cases/rain"
className="
inline-flex items-center justify-center gap-3
w-[260px] h-[52px]
bg-[#01ACE5] text-white
rounded-sm
hover:bg-[#0A78C0]
transition-colors
"
>
물 재이용 현황 보러가기
<span className="text-lg">→</span>
</a>
</div>
</div>
</div>
{/* 우측 : 이미지 카드 */}
<div className="flex justify-center lg:justify-end">
<div
className="
relative
w-full max-w-[720px]
"
>
<img
src="/images/landing/section_03.png"
alt="물 재이용 현황 데이터 테이블"
className="w-full h-auto object-cover"
/>
</div>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,29 @@
// apps/dm/app/(landing)/reuse/_sections/Hero.tsx
import styles from "../reuse.module.css";
export default function Hero() {
return (
<section id="main" className={styles.hero}>
<div className={styles.heroBg} aria-hidden />
<div className={styles.heroOverlay} aria-hidden />
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="flex flex-col items-center justify-items-center gap-[40px] mx-auto max-w-[1520px] px-6 text-center text-white">
<h1 className="text-5xl font-bold tracking-tight">물 재이용</h1>
<p className="text-xl font-medium leading-6 text-white/85 max-w-[720px] mx-auto">
빗물, 오수, 하수처리수, 폐수처리수, 발전소 온배수를 재이용처리하여<br />
생활, 공업, 농업, 조경, 하천유지 등의 용도로 재이용합니다.
</p>
<p className="text-lg leading-6 text-white/85 max-w-[720px] mx-auto">
전국 하수 재이용률 15.6%(2024년 통계 기준),<br />
물재이용 활성화를 위한 재이용 수요신청, 매칭을 추진하고 있습니다.
</p>
</div>
</div>
<div className="absolute inset-x-0 bottom-[120px] flex flex-col items-center justify-items-center gap-2 mt-10">
<img src="/images/landing/scroll_down.svg" alt="" className="w-[26px] h-[40px]" />
<span className="text-white/80 text-base tracking-[.25em]">scroll down</span>
</div>
</section>
);
}
@@ -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 (
<section id="intro" className="bg-white">
<div className="mx-auto max-w-[1520px] px-6 py-[90px] h-[870px]">
<div className="text-center">
<div className="inline-block">
<h2 className="relative z-10 px-2 text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 소개</h2>
<div className="relative top-[-7px] -z-1 h-4 bg-gradient-to-r from-sky-300 to-green-300"></div>
</div>
<p className="mt-5 text-xl text-gray-500">
지속 가능한 미래를 위한 약속, 깨끗한 물의 새로운 순환을 시작합니다.
</p>
</div>
<section className="mx-auto max-w-8xl px-4 py-12">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{INTRO_CARDS.map((card, idx) => (
<Link
key={idx}
href={card.href}
aria-label={`${card.title} 상세로 이동`}
className="group relative h-[280px] md:h-[320px] lg:h-[500px] overflow-hidden rounded-2xl cursor-pointer focus:outline-none focus:ring-2 focus:ring-sky-400/60"
>
{/* 배경 이미지 (이미 그라디언트 포함) */}
<img
src={card.image}
alt={card.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 hover:scale-105"
/>
{/* 텍스트 영역 */}
<div className="absolute bottom-0 z-10 p-6 text-white">
<h3 className="text-lg font-semibold tracking-tight">
{card.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-white/90 line-clamp-3">
{card.desc}
</p>
</div>
</Link>
))}
</div>
</section>
</div>
</section>
);
}
@@ -0,0 +1,22 @@
// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx
import CaseTabsCarousel from "../_components/CaseTabsCarousel";
export default function ProcessFlow() {
return (
<section id="processflow" className="bg-white">
<div className="mx-auto max-w-[1520px] px-6 py-[50px] h-[870px]">
<div className="text-center">
<div className="inline-block">
<h2 className="relative z-10 px-2 text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 사례</h2>
<div className="relative top-[-7px] -z-1 h-4 bg-gradient-to-r from-sky-300 to-green-300"></div>
</div>
</div>
{/* 탭메뉴 영역 */}
<CaseTabsCarousel />
</div>
</section>
);
}
@@ -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 (
<div className="flex gap-0">
<Lnb title="수요신청" items={LNB_DEMAND} />
<main className="flex-1 px-[100px] pt-[80px]">{children}</main>
</div>
);
}
@@ -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 (
<div className="fixed inset-0 z-[120]">
{/* Backdrop */}
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>
{/* Dialog */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-[1100px] rounded-[18px] bg-white shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="relative px-10 pt-9 pb-6">
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute right-7 top-7 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
>
<span className="text-2xl leading-none">×</span>
</button>
<h2
id={titleId}
className="text-left text-[28px] font-bold tracking-tight text-gray-900"
>
{title}
</h2>
<div className="mt-5 h-px w-full bg-gray-200" />
</div>
{/* Body (스크롤 영역) */}
<div className="px-10 pb-10">
<div className="max-h-[70vh] overflow-auto bg-white">
{/* 이미지가 길어도 여기서 스크롤 */}
<Image
src={imageSrc}
alt="수질 기준 참고"
width={851}
height={2334}
className="block mx-auto max-w-full h-auto"
priority
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -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<SidoValue, Option[]> = {
"": [{ 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 <div className="h-px w-full bg-neutral-200" />;
}
function FormRow({
label,
required,
children,
}: {
label: React.ReactNode;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
{/* 라벨(좌측) */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6">
<div className="flex items-center gap-1 text-lg font-semibold text-neutral-800">
<span>{label}</span>
{required ? <span className="text-red-500">*</span> : null}
</div>
</div>
{/* 입력(우측) */}
<div className="col-span-12 lg:col-span-10 px-6 py-6">
{children}
</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
className = "",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
type={type}
value={value}
disabled={disabled}
onChange={(e) => 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<T extends string>({
value,
onChange,
options,
className = "",
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
className?: string;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value as T)}
className={[
"h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800",
"focus:border-neutral-400 focus:outline-none",
className,
].join(" ")}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export default function DemandPlaceRegisterPage() {
const [sido, setSido] = useState<SidoValue>("");
const [sigungu, setSigungu] = useState<string>("");
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<SidoValue,...> + sido가 SidoValue)
const sigunguOptions = useMemo<Option[]>(() => 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 (
<main className="bg-white">
<SubPageHeader
title="수요처등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요처등록" },
]}
/>
{/* register/page.tsx와 동일한 테이블 룩앤필 */}
<div className="border border-neutral-200 bg-white">
<FormRow label="시도" required>
<SelectBox<SidoValue>
value={sido}
onChange={(v) => {
setSido(v);
setSigungu(""); // 시도 변경 시 초기화
}}
options={SIDO}
/>
</FormRow>
<Divider />
<FormRow label="시군구" required>
<select
value={sigungu}
onChange={(e) => setSigungu(e.target.value)}
className="h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800 focus:border-neutral-400 focus:outline-none"
>
{sigunguOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</FormRow>
<Divider />
<FormRow label="회사명" required>
<div className="max-w-[420px]">
<TextInput
value={company}
onChange={setCompany}
placeholder="회사명을 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="사업자등록번호" required>
<div className="max-w-[420px]">
<TextInput
value={bizNo}
onChange={setBizNo}
placeholder="사업자 등록번호를 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="종사자등록번호">
<div className="flex flex-wrap items-center gap-4">
<div className="w-[420px] max-w-full">
<TextInput
value={workerNo}
onChange={setWorkerNo}
placeholder="종사자등록번호를 입력해주세요."
disabled={noWorkerNo}
/>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noWorkerNo}
onChange={(e) => {
setNoWorkerNo(e.target.checked);
if (e.target.checked) setWorkerNo("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
해당없음
</label>
</div>
</FormRow>
<Divider />
<FormRow label="사업자주소" required>
<div className="max-w-[420px] space-y-2">
<div className="flex gap-2">
<div className="w-[180px]">
<TextInput value={zip} onChange={setZip} placeholder="우편번호" />
</div>
<button
type="button"
onClick={() => alert("우편번호 검색(샘플)")}
className="h-11 rounded bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
>
우편번호 검색
</button>
</div>
<TextInput value={addr1} onChange={setAddr1} placeholder="주소" />
<TextInput
value={addr2}
onChange={setAddr2}
placeholder="상세 주소를 입력하세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="전화번호" required>
<div className="max-w-[420px]">
<TextInput
value={tel}
onChange={setTel}
placeholder="전화번호를 입력하세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호" required>
<div className="max-w-[420px]">
<TextInput
value={pw}
onChange={setPw}
placeholder="비밀번호를 입력하세요."
type="password"
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호 확인" required>
<div className="max-w-[420px]">
<TextInput
value={pw2}
onChange={setPw2}
placeholder="비밀번호를 한번 더 입력하세요."
type="password"
/>
</div>
</FormRow>
</div>
{/* register/page.tsx와 동일하게 하단 중앙 버튼 */}
<div className="flex justify-center py-12">
<button
type="button"
onClick={onSubmit}
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
>
수요처 등록
</button>
</div>
</main>
);
}
@@ -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<string>(today);
const [reqQty, setReqQty] = useState<string>("");
const [usePurpose, setUsePurpose] = useState<string>("");
const [hopePrice, setHopePrice] = useState<string>("");
// 요구수질
const [coliform, setColiform] = useState<string>("");
const [noDetect, setNoDetect] = useState<boolean>(false);
const [tn, setTn] = useState<string>("");
const [residualCl, setResidualCl] = useState<string>("");
const [tp, setTp] = useState<string>("");
const [turbidity, setTurbidity] = useState<string>("");
const [ph, setPh] = useState<PHRange>({ min: "", max: "" });
const [bod, setBod] = useState<string>("");
const [chloride, setChloride] = useState<string>("");
const [odor, setOdor] = useState<string>("불쾌하지 않을 것");
const [ec, setEc] = useState<string>("");
const [color, setColor] = useState<string>("");
const [remark, setRemark] = useState<string>("");
// 공급처 후보지 선택(필수아님)
const [cand1, setCand1] = useState("");
const [cand2, setCand2] = useState("");
const [cand3, setCand3] = useState("");
return (
<>
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요정보등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요정보등록" },
]}
/>
{/* 컨텐츠 영역 */}
<div>
{/* 기본정보 테이블 */}
<div className="border border-neutral-200 bg-white">
<FormRow label="요구시기" required>
<DateInput value={reqDate} onChange={setReqDate} />
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<TextInput value={reqQty} onChange={setReqQty} placeholder="" />
</FormRow>
<Divider />
<FormRow label="사용용도" required>
<TextInput value={usePurpose} onChange={setUsePurpose} />
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
{/* 요구수질 섹션 타이틀 바 */}
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 inline-flex justify-center items-center gap-3 px-6 py-6 text-center">
<span className="text-lg font-semibold text-neutral-800">요구수질</span>
<button
type="button"
onClick={() => setOpenRef(true)}
className="rounded border-[2px] border-[#DE3412] px-2 py-3 text-[15px] font-medium text-[#4F4F4F] hover:bg-orange-50"
>
수질기준 참고
</button>
{/* 수질기준참고 모달 */}
<WaterQualityRefModal
open={openRef}
onClose={() => setOpenRef(false)}
imageSrc="/images/landing/water_quality_ref_1.png"
/>
</div>
</div>
{/* 요구수질 테이블 */}
<div className="border border-neutral-200 bg-white">
{/* 1행 */}
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftRequired
leftControl={
<div className="flex items-center gap-4">
<TextInput
value={coliform}
onChange={setColiform}
disabled={noDetect}
className={noDetect ? "bg-neutral-100 text-neutral-400" : ""}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noDetect}
onChange={(e) => {
setNoDetect(e.target.checked);
if (e.target.checked) setColiform("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
}
rightLabel="총질소(T-N)(mg/L)"
rightControl={
<InlineSuffixInput value={tn} onChange={setTn} suffix="이하" />
}
/>
<Divider />
{/* 2행 */}
<QualityRow
leftLabel="결합잔류염소(mg/L)"
leftRequired
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
rightLabel="총인(T-P)(mg/L)"
rightControl={
<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />
}
/>
<Divider />
{/* 3행 */}
<QualityRow
leftLabel="탁도(NTU)"
leftRequired
leftControl={
<InlineSuffixInput value={turbidity} onChange={setTurbidity} suffix="이하" />
}
rightLabel="수소이온농도(pH)"
rightControl={
<div className="flex items-center gap-2">
<TextInput
value={ph.min}
onChange={(v) => setPh((p) => ({ ...p, min: v }))}
className="w-32"
/>
<span className="text-neutral-500">~</span>
<TextInput
value={ph.max}
onChange={(v) => setPh((p) => ({ ...p, max: v }))}
className="w-32"
/>
</div>
}
/>
<Divider />
{/* 4행 */}
<QualityRow
leftLabel={
<>
생물화학적산소요구량
<br />
(BOD)(mg/L)
</>
}
leftRequired
leftControl={<InlineSuffixInput value={bod} onChange={setBod} suffix="이하" />}
rightLabel="염화물(mgCL/L)"
rightControl={<InlineSuffixInput value={chloride} onChange={setChloride} suffix="이하" />}
/>
<Divider />
{/* 5행 */}
<QualityRow
leftLabel="냄새"
leftRequired
leftControl={
<select
value={odor}
onChange={(e) => setOdor(e.target.value)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
}
rightLabel="전기전도도(µs/cm)"
rightControl={<InlineSuffixInput value={ec} onChange={setEc} suffix="이하" />}
/>
<Divider />
{/* 6행 */}
<QualityRow
leftLabel="색도(도)"
leftRequired
leftControl={<InlineSuffixInput value={color} onChange={setColor} suffix="이하" />}
rightLabel="비고"
rightControl={
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="h-11 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500"
/>
}
/>
<Divider />
{/* 후보지 선택 */}
<div className="grid grid-cols-12">
<LabelCell>
공급처 후보지 선택
<br />
(필수아님)
</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-4">
<TextInput value={cand1} onChange={setCand1} className="max-w-[220px]" />
<TextInput value={cand2} onChange={setCand2} className="max-w-[220px]" />
<TextInput value={cand3} onChange={setCand3} className="max-w-[220px]" />
<div className="flex items-center gap-3 xl:ml-2">
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => {
// TODO: 후보지 조회 모달/검색 연결
console.log("후보지 조회", { cand1, cand2, cand3 });
}}
>
후보지 조회
</button>
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-600"
onClick={() => {
// TODO: 유사사례 연결
console.log("유사사례");
}}
>
유사사례
</button>
</div>
</div>
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="flex justify-center py-12">
<button
type="button"
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
onClick={() => {
// TODO: 저장 처리
console.log("수요정보 등록");
}}
>
수요정보 등록
</button>
</div>
</div>
</main>
</>
);
}
/* ----------------------------- UI helpers ----------------------------- */
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function LabelCell({
children,
required,
}: {
children: React.ReactNode;
required?: boolean;
}) {
return (
<div className="col-span-12 md:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
{children}
{required ? <span className="text-red-500">*</span> : null}
</span>
</div>
);
}
function FormRow({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
<LabelCell required={required}>{label}</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">{children}</div>
</div>
);
}
function QualityRow({
leftLabel,
leftRequired,
leftControl,
rightLabel,
rightControl,
}: {
leftLabel: React.ReactNode;
leftRequired?: boolean;
leftControl: React.ReactNode;
rightLabel: React.ReactNode;
rightControl: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
{/* 좌측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
<span className="leading-6">{leftLabel}</span>
{leftRequired ? <span className="text-red-500">*</span> : null}
</span>
</div>
{/* 좌측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{leftControl}</div>
{/* 우측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="leading-6">{rightLabel}</span>
</div>
{/* 우측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{rightControl}</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
disabled,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={[
"h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none",
"focus:border-neutral-500",
disabled ? "cursor-not-allowed" : "",
className ?? "",
].join(" ")}
/>
);
}
function InlineSuffixInput({
value,
onChange,
suffix,
}: {
value: string;
onChange: (v: string) => void;
suffix: string;
}) {
return (
<div className="flex items-center gap-3">
<TextInput value={value} onChange={onChange} className="max-w-[200px]" />
<span className="text-sm text-neutral-600">{suffix}</span>
</div>
);
}
function DateInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
const openPicker = () => {
const el = ref.current;
if (!el) return;
// Chrome/Edge 등 지원
if (typeof (el as any).showPicker === "function") {
(el as any).showPicker();
return;
}
// fallback (일부 브라우저)
el.focus();
el.click();
};
return (
<div className="relative w-full max-w-[200px]">
<input
ref={ref}
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
"[&::-webkit-calendar-picker-indicator]:opacity-0",
"[&::-webkit-calendar-picker-indicator]:absolute",
"[&::-webkit-calendar-picker-indicator]:right-0",
"[&::-webkit-calendar-picker-indicator]:w-10",
"[&::-webkit-calendar-picker-indicator]:h-full",
"[&::-webkit-calendar-picker-indicator]:cursor-pointer",
].join(" ")}
/>
{/* ✅ 클릭 가능한 아이콘 버튼 */}
<button
type="button"
onClick={openPicker}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700"
aria-label="달력 열기"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
);
}
@@ -0,0 +1,338 @@
// apps/dm/app/(landing)/reuse/apply/status/page.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
type WaterDetail = {
toc: string;
bdo: string;
tn: string;
tp: string;
cl: string;
conductivity: string;
coliform: string;
turbidity: string;
odor: string;
color: string;
ph: string;
note: string;
};
type Row = {
id: number;
sido: string;
sigungu: string;
company: string;
address: string;
phone: string;
date: string;
status: "진행중" | "진행완료";
detail: {
reqDate: string;
reqQty: string;
usePurpose: string;
price: string;
water: WaterDetail;
candidates: [string, string, string];
};
};
const MOCK: Row[] = [
{
id: 1,
sido: "서울특별시",
sigungu: "금천구",
company: "서울워터솔루션(주)",
address: "서울특별시 금천구 가산디지털2로 119, 24층(가산동)",
phone: "010-0000-0000",
date: "2025-01-14",
status: "진행중",
detail: {
reqDate: "2025-02",
reqQty: "1,200",
usePurpose: "공업용수",
price: "350",
water: {
toc: "4",
bdo: "3",
tn: "10",
tp: "1.0",
cl: "120",
conductivity: "800",
coliform: "0",
turbidity: "2",
odor: "무",
color: "무",
ph: "7.2",
note: "-",
},
candidates: ["후보지1", "후보지2", "후보지3"],
},
},
{
id: 2,
sido: "제주특별자치도",
sigungu: "서귀포",
company: "회사명이 길어도괜찮지",
address: "제주특별자치도 서귀포시 천제연로 156, 3층 2호(색달동 1077호)",
phone: "010-0000-0000",
date: "2025-01-14",
status: "진행완료",
detail: {
reqDate: "2025-03",
reqQty: "800",
usePurpose: "조경용수",
price: "300",
water: {
toc: "5",
bdo: "4",
tn: "12",
tp: "1.2",
cl: "140",
conductivity: "900",
coliform: "0",
turbidity: "3",
odor: "무",
color: "무",
ph: "7.0",
note: "현장 협의",
},
candidates: ["후보지1", "후보지2", "후보지3"],
},
},
];
function StatusBadge({ v }: { v: Row["status"] }) {
const cls = v === "진행중" ? "text-red-600" : "text-lime-600";
return <span className={["font-medium", cls].join(" ")}>{v}</span>;
}
type TopField = { label: string; value: string; required?: boolean };
type PairField = { label: string; value: string };
export default function DemandStatusPage() {
const rows = useMemo(() => MOCK, []);
// ✅ rows가 빈 경우도 대비 (런타임 오류 방지)
const [selectedId, setSelectedId] = useState<number | null>(rows[0]?.id ?? null);
// ✅ 상세영역 스크롤 이동용
const detailRef = useRef<HTMLDivElement | null>(null);
// ✅ selected 계산 안정화
const selected = useMemo(() => {
if (!rows.length) return null;
const found = rows.find((r) => r.id === selectedId);
return found ?? rows[0];
}, [rows, selectedId]);
// ✅ rows가 바뀌었는데 selectedId가 null이면 자동 첫번째 선택
useEffect(() => {
const first = rows[0];
if (!first) return; // ✅ rows가 비어있으면 종료 (TS + 런타임 안전)
if (selectedId == null) setSelectedId(first.id);
}, [rows, selectedId]);
// ✅ 상단 2행(요구시기/요구수량/사용용도/희망단가) - TS 안전하게 object로 구성
const topFields: TopField[] = useMemo(() => {
if (!selected) return [];
return [
{ label: "요구시기", value: selected.detail.reqDate, required: true },
{ label: "요구수량(㎡/d)", value: selected.detail.reqQty },
{ label: "사용용도", value: selected.detail.usePurpose },
{ label: "희망단가(원)", value: selected.detail.price },
];
}, [selected]);
// ✅ 수질 항목 - TS 안전
const waterFields: PairField[] = useMemo(() => {
if (!selected) return [];
const w = selected.detail.water;
return [
{ label: "총대장균군수(개/100mℓ)", value: w.coliform },
{ label: "결합잔류염소(mg/L)", value: "-" },
{ label: "탁도(NTU)", value: w.turbidity },
{ label: "생물화학적산소요구량(BOD)(mg/L)", value: w.bdo },
{ label: "냄새", value: w.odor },
{ label: "색도(도)", value: w.color },
{ label: "총질소(T-N)(mg/L)", value: w.tn },
{ label: "총인(T-P)(mg/L)", value: w.tp },
{ label: "수소이온농도(pH)", value: w.ph },
{ label: "염화물(mgCL/L)", value: w.cl },
{ label: "전기전도도(µs/cm)", value: w.conductivity },
{ label: "비고", value: w.note },
];
}, [selected]);
const onSelectRow = (id: number) => {
setSelectedId(id);
// ✅ “행 클릭 → 아래 상세로 보여짐” 체감 확실히: 상세영역으로 이동
// (사용자가 이미 아래를 보고 있다면 거의 움직이지 않음)
requestAnimationFrame(() => {
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
};
return (
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요신청현황"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요신청현황" },
]}
hideFooterLine
hideGuideText
/>
{/* 컨텐츠 영역 */}
<div>
{/* 리스트 테이블 */}
<section className="mt-6">
<div className="text-2xl font-bold text-slate-700">수요정보 신청현황</div>
<div className="mt-3 border border-neutral-200">
<table className="w-full table-fixed">
<thead className="bg-[#E7F4E2]">
<tr className="text-sm text-slate-700">
<th className="w-[60px] px-3 py-3 text-center">NO.</th>
<th className="w-[120px] px-3 py-3 text-center">시도</th>
<th className="w-[120px] px-3 py-3 text-center">시군구</th>
<th className="w-[180px] px-3 py-3 text-center">회사명</th>
<th className="px-3 py-3 text-center">공장주소</th>
<th className="w-[140px] px-3 py-3 text-center">전화번호</th>
<th className="w-[120px] px-3 py-3 text-center">신청일자</th>
<th className="w-[110px] px-3 py-3 text-center">진행상태</th>
</tr>
</thead>
<tbody className="bg-white text-base text-slate-700">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-10 text-center text-slate-400">
조회된 신청내역이 없습니다.
</td>
</tr>
) : (
rows.map((r) => {
const active = r.id === selected?.id;
return (
<tr
key={r.id}
role="button"
tabIndex={0}
onClick={() => onSelectRow(r.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelectRow(r.id);
}}
className={[
"border-t border-neutral-200 outline-none",
"cursor-pointer",
active ? "bg-slate-50" : "hover:bg-slate-50",
"focus-visible:ring-2 focus-visible:ring-slate-300",
].join(" ")}
aria-selected={active}
>
<td className="px-3 py-3 text-center">{r.id}</td>
<td className="px-3 py-3 text-center">{r.sido}</td>
<td className="px-3 py-3 text-center">{r.sigungu}</td>
<td className="px-3 py-3 text-center">{r.company}</td>
<td className="px-3 py-3 text-center">{r.address}</td>
<td className="px-3 py-3 text-center">{r.phone}</td>
<td className="px-3 py-3 text-center">{r.date}</td>
<td className="px-3 py-3 text-center">
<StatusBadge v={r.status} />
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
{/* 상세현황 */}
<section className="mt-10 pb-16" ref={detailRef}>
<div className="text-2xl font-bold text-slate-700">수요신청 상세현황</div>
{!selected ? (
<div className="mt-3 border border-neutral-200 px-6 py-10 text-center text-sm text-slate-400">
상세 정보를 표시할 신청건이 없습니다.
</div>
) : (
<div className="mt-3 border border-neutral-200">
<div className="bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수량
</div>
<div className="grid grid-cols-12 text-sm">
{/* 상단 2행 */}
{topFields.map((f) => (
<div
key={f.label}
className="col-span-12 md:col-span-6 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-4 bg-sky-50 px-4 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{f.label}
{f.required ? <span className="ml-1 text-red-500">*</span> : null}
</div>
<div className="col-span-8 px-4 py-4 flex items-center justify-center text-center">
{f.value}
</div>
</div>
))}
{/* 수질 */}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수질
</div>
{waterFields.map((f) => (
<div
key={f.label}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{f.label}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{f.value}
</div>
</div>
))}
{/* 공급처 후보지 */}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
공급처 후보지
</div>
{selected.detail.candidates.map((v, i) => (
<div
key={`candidate-${i}`}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
후보지{i + 1}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{v}
</div>
</div>
))}
</div>
</div>
)}
</section>
</div>
</main>
);
}
@@ -0,0 +1,151 @@
// apps/dm/app/(landing)/reuse/business/globalCase/australia/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "호주", href: "/reuse/business/globalCase/australia" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/australia_01.png", alt: "호주 사례 1" },
]}
/>
{/* 생활용수로 재이용수의 적극 활용 */}
<section className="mt-8">
<SectionTitle>생활용수로 재이용수의 적극 활용</SectionTitle>
<div className="px-4 mt-6">
<ul className="space-y-4 text-base text-[#4F4F4F]">
<li>
– 재이용수를 공원, 골프장 등의 관개용수로 공급(초록색 지역)
</li>
<li>
– Dual pipe로 재이용수를 특정 주거단지로 공급(보라색 지역)
</li>
<li>
– 현재 7,000여 가구가 재이용수를 사용
</li>
<li>
– 2030년까지 43,000가구로 확대 예정
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 호주 재이용수 광역 활용(퀸즈랜드주 브리즈번) */}
<section className="mt-6">
<SectionTitle>호주 재이용수 광역 활용(퀸즈랜드주 브리즈번)</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망
</li>
<li>
• 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급<br />
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>– 1,618억의 사업비 중 92% 국비, 지방비 8% 투입</li>
<li>– 재이용수는 24km 관로로 타이난 TSMC로 공급</li>
</ul>
</li>
<li>
• 12개 댐, 32개 수처리 시설, 3개 재이용시설, 담수화 플랜트 1개, 저수지 28개와 600km 이상의 물 공급 관로를 건설
</li>
<li>
• 지역적으로 강수량이 고르지 않아, 댐의 저수율이 불균등할 때 지역의 물 공급을 보충하는 중요한 역할을 수행
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주는 2000~2009년 강수량 감소로 밀레니엄 가뭄이 발생하여 심각한 물 부족 현상이 발생함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주는 해안 도시에 인구가 밀집하여 거주하고 있으며 인구 증가율이 타 선진국 대비 높아, 향후 도시에 공급할 수자원 확보가 필요함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주정부는 1990년대부터 하·폐수가 방류될 경우의 환경 피해에 대해 인식하기 시작하고 배출규제와 위원회를 구성하기 시작</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>연방정부는 주에 인센티브를 주는 방식으로 물 정책을 장려하고 토지소유권과 물 권리를 구분하여 거래를 허용하고 물 재이용 활성화를 위한 Water Smart Australia 프로그램을 시행</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주의 용수공급 체계는 영국과 미국의 특성들이 존재하지만, 주의 특성과 주 정부의 정책 방향에 따라 주마다도 많은 차이가 있음. 특히 대부분의 주에서 수 자원 개발 기능과 수도 공급의 기능을 분리하여 주정부<br/>산하 기관에서 운영하는게 특징</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,142 @@
// apps/dm/app/(landing)/reuse/business/globalCase/eu/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[EU] 법률을 제정, EU국가에서 물 재이용 유도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "EU", href: "/reuse/business/globalCase/eu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/eu_01.png", alt: "EU 사례 1" },
]}
/>
{/* 물 재이용에 대한 법률을 제정(2020 제정, 2023 시행) */}
<section className="mt-8">
<SectionTitle>물 재이용에 대한 법률을 제정(2020 제정, 2023 시행)</SectionTitle>
<div className="px-4 mt-6">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741)
</li>
<li>
• 2023년부터 모든 EU국가에서 시행
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 유럽연합 회원국 물 재이용 지원정책 */}
<section className="mt-6">
<SectionTitle>유럽연합 회원국 물 재이용 지원정책</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 유럽연합 차원에서 다양한 물 재이용 프로그램 자금 지원<br />
<ul className="list-inside space-y-4 text-[#767676] text-sm ml-4 mt-2">
<li>– 2021년부터 3년간 Life RECYCLIO 프로그램 지원 → 세부내용 물 재이용 프로그램<br/>
<ul className="list-inside space-y-2 text-[#767676] ml-4 mt-2">
<li>1. 환경 관련 사업에 자금을 지원하는 Life 프로그램의 일환으로 80만 유로를 지원</li>
<li>2. 식품·농업 분야 물 재이용 확대를 위해 기술 이전 및 평가 실시</li>
</ul>
</li>
<li>– 물 재이용 정책 수행을 위해 BOOST-INN 180만 유로를 지원<br/>
<ul className="list-inside space-y-2 text-[#767676] ml-4 mt-2">
<li> *프랑스, 이탈리아, 스페인 등 유럽 선진국 솔루션 공유</li>
</ul>
</li>
</ul>
</li>
<li>
• EU산하 JRC(공동연구센터)에서 물 재이용 기술제공 지원<br />
<ul className="list-inside space-y-2 text-[#767676] text-sm ml-4 mt-2">
<li>‘물 재이용 회원 관리 실무 커뮤니티(Water Reuse Management Community of Practice)’ 시범사업 수행</li>
<li>유럽 전역에 성공적인 모델 활용 및 혁신적인 물 재이용 솔루션을 확장할 수 있는 기술을 제공</li>
</ul>
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>2019년 기준 EU 인구의 38%가 물 부족의 영향을 받을 것으로 예상됨. 이 흐름대로면 2030년까지 유럽 유역 전반에 물 부족 문제가 발생하게 될 것으로 예상. 인구 증가, 도시화의 확대, 염분화 및 기후변화들이 수자원 문제들의 주요 요인이며, 식습관 변화와 산업용수 수요 증가 등으로 악화되고 있는 상황</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>유럽의 물 부족 상황은 지역에 따라 다르게 나타나 스페인을 비롯한 남부유럽 에서 특히 심각함. 전반적으로 북유럽에 비해 남유럽의 물 스트레스가 심하며, 특히 스페인과 포르투갈, 프랑스 동부지역의 물 스트레스가 심각함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>유럽 연합에서도 물 부족 문제를 인식하고 2000년 EU Water Framework Directive을 개발, 이후 2020년 재이용에 관한 법률을 제정</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,229 @@
// apps/dm/app/(landing)/reuse/business/globalCase/overview/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="해외 재이용 사례 개요"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "사례개요", href: "/reuse/business/globalCase/overview" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/overview_01.png", alt: "해외 재이용 사례 1" },
]}
/>
<section className="mt-8">
<div className="rounded-md bg-[#e9f0f1] p-8">
<h3 className="text-xl font-bold text-gray-800 leading-relaxed">
2024년 하수도 통계 기준 15.6%이며,<br />
제2차 물 재이용 기본 계획을 고려한 2030년 목표는 <span className="font-extrabold">17%</span>
</h3>
{/* 진행 바 영역 */}
<div className="mt-6 space-y-4">
{/* 2024 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">’24년도</span>
<div className="relative h-7 rounded-r-lg">
<div className="h-7 w-[112px] rounded-r-lg bg-gray-400" />
</div>
<span className="text-sm text-gray-700">15.6%</span>
</div>
{/* 2030 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">’30년도</span>
<div className="relative">
<div className="h-10 w-[327px] rounded-r-lg bg-gradient-to-r from-sky-500 to-green-500" />
</div>
<span className="text-xl font-bold text-gray-900">17%</span>
</div>
</div>
</div>
{/* 설명 문구 */}
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 우리나라의 하수 처리 재이용률은 <span className="text-sky-600 ">15% 수준</span>
</li>
<li>
• 대만은 가뭄과 공업용수 증가에 대응하여
<span className="text-sky-600 ">
{" "}현재 4.3% 수준이며, 16.5%까지 확대 계획 수립
</span>
</li>
</ul>
{/* 테이블 */}
<div className="mt-6">
<table className="w-full text-center text-sm">
<thead>
<tr>
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">해외정보</th>
<th className="bg-teal-500 px-4 py-3 text-white rounded-t-lg">현재</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">미래(목표, 예측치)</th>
</tr>
</thead>
<tbody className="bg-white border border-t-0">
<tr className="border-t">
<td className="px-4 py-3 font-medium">한국</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">12.1%</td>
<td className="px-4 py-3">15.6%</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">17%</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 font-medium">대만</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">2.4%</td>
<td className="px-4 py-3">4.3%</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">16.5%</td>
</tr>
</tbody>
</table>
</div>
</section>
<Hr className="my-11" />
{/* 주요 국가의 하수처리수 재이용률 (%) */}
<section className="pb-[200px]">
<SectionTitle>주요 국가의 하수처리수 재이용률 (%)</SectionTitle>
{/* 설명 문구 */}
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐
</li>
<li>
• 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편
</li>
</ul>
{/* 테이블 (2단 병렬: 좌/우 데이터) */}
<div className="mt-6">
<div className="overflow-x-auto">
<table className="min-w-[900px] w-full text-center text-sm">
<thead>
<tr>
{/* 좌측 3컬럼 */}
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
{/* 우측 3컬럼 */}
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
</tr>
</thead>
<tbody className="bg-white border border-t-0">
{[
{
L: { c: "이스라엘", r: "중동&북아프리카", v: "92.3" },
R: { c: "튀르키예", r: "동유럽&중앙아시아", v: "26.6" },
},
{
L: { c: "쿠웨이트", r: "중동&북아프리카", v: "89.3" },
R: { c: "독일", r: "서유럽", v: "25.0" },
},
{
L: { c: "호주", r: "동아시아&태평양", v: "49.0" },
R: { c: "스페인", r: "서유럽", v: "24.4" },
},
{
L: { c: "싱가포르", r: "동아시아&태평양", v: "44.4" },
R: { c: "한국", r: "동아시아&태평양", v: "12.1" },
},
{
L: { c: "그리스", r: "서유럽", v: "39.1" },
R: { c: "미국", r: "북아메리카", v: "13.1" },
},
{
L: { c: "사우디아라비아", r: "중동&북아프리카", v: "32.8" },
R: { c: "프랑스", r: "서유럽", v: "9.5" },
},
{
L: { c: "이탈리아", r: "서유럽", v: "31.7" },
R: { c: "대만", r: "동아시아&태평양", v: "2.4" },
},
{
L: { c: "룩셈부르크", r: "서유럽", v: "29.1" },
R: { c: "일본", r: "동아시아&태평양", v: "0.8" },
},
{
L: { c: "포르투갈", r: "서유럽", v: "29.0" },
R: { c: "", r: "", v: "" }, // 마지막 우측은 빈칸(이미지처럼)
},
].map((row, idx) => (
<tr key={idx} className="border-t">
{/* 좌측 */}
<td className="px-4 py-3 font-medium text-gray-700">{row.L.c}</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.L.r}</td>
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.L.v}</td>
{/* 우측 */}
<td className="px-4 py-3 font-medium text-gray-700">{row.R.c}</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.R.r}</td>
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.R.v}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,172 @@
// apps/dm/app/(landing)/reuse/business/globalCase/singapore/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[싱가포르] 수요가 높은 산업의 물 재이용 의무화"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "싱가포르", href: "/reuse/business/globalCase/singapore" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/singapore_01.png", alt: "싱가포르 사례 1" },
]}
/>
{/* 싱가포르 NEWater 비중 확대 */}
<section className="mt-8">
<SectionTitle>싱가포르 NEWater 비중 확대</SectionTitle>
<div className="px-4 mt-6">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설
</li>
<li>
• NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용
</li>
<li>
• 2060년까지 NEWater 비중 55%까지 높이는 것이 싱가포르의 목표
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 싱가포르 재이용수 의무화 및 지원정책 */}
<section className="mt-6">
<SectionTitle>싱가포르 재이용수 의무화 및 지원정책</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 공업용수 수요가 높은 산업에 대해 물 재이용을 의무화<br />
<ul className="list-inside text-[#767676] text-sm ml-4 mt-2">
<li>– 반도체업계 전체 비 재이용수(non-domestic용수) 17% 차지 → 공업용수 수요 원인</li>
<li>– 2024년 1월 1일부터 연간 최소 60,000㎥를 소비하는 사업자는 재이용수 사용 의무화</li>
<li>– 반도체 공장의 경우 최소 50% 이상의 재이용수를 요구함(현재는 43% 정도)</li>
</ul>
</li>
<li>
• 재정 지원을 통해 재이용 활성화를 지원<br />
<ul className="list-inside text-[#767676] text-sm ml-4 mt-2">
<li>– 재이용 설비를 기준으로 2025년까지 싱가포르 수자원공사의 2가지 금융상품 제공<br/>
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>1. Water Efficiency Fund</li>
<li>2. Industrial Water Solutions Demonstration Fund</li>
</ul>
</li>
<li className="mt-2">– 2025년 기준 지원 한도 상향 → 물 재이용 활성 도모<br/>
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>투입비용 50% → 75% 지원 / WSDP: $400만 → $500만</li>
<li>물 효율이 높은 장비를 도입하는 데에도 최대 $300만 지원 가능</li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>싱가포르는 천연 수자원이 많지 않아, 말레이시아 조호르와 1962년 맺은 협약으로 물을 수입해왔으며, 재이용수를 늘리기 위한 노력을 계속하고 있음<br/>
<ul className="list-inside text-[#767676] text-sm ml-4 mt-2">
<li>- 싱가포르는 국토 내에 호수와 하천이적고 집수공간도 적어 빗물로 확보할 수 있는 수자원량은 전체 물 수요의 20%에 불과함</li>
</ul>
</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>싱가포르는 도심에 저수지를 건설하 고 건물에 빗물이용시설을 건설하여 빗물을 재이용하고 있음</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>싱가포르는 2002년부터 하수재이용수인 NEWater를 개발, 비음 용수와 간접음용수로 활용하고 있음<br/>
<ul className="list-inside text-[#767676] text-sm/6 ml-4 mt-2">
<li>- 재이용수를 NEWater라는 이름으로 명명하고 NEWater에 대한 신뢰 확보를 위해 NEWater 방문자 센터도 건설함</li>
<li>- 비음용수로 많이 사용하며 건기에는 NEWater를 저수지에 추가하여 원수와 섞음. 원수는 상수도에서 처리 한 후 가정에 수돗물로 공급함</li>
<li>- NEWater는 전용 파이프를 통해 주로 웨이퍼 제조 공장, 산업단지, 상업용 건물에서 산업 및 냉각수로 사용. 제일 많이 사용하는 곳은 웨이퍼 제조 공장</li>
</ul>
</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>2024년 4월 기준 1㎥ 당 공업용수 가격은 $1.66이며, Non-domestic 기준 NEWater 가격은 $2.41</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>공업용수 가격 $1.66은 수도요금(water tariff)과 수자원세(waterborne tax)로 구분됨. $1.66에는 $1만큼의 수자원세가 포함됨</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>2023년 싱가포르는 산업용수 수요가 높은 산업에 대해 물 재 이용을 의무화했으며, 정부는 기금을 통해 물 재이용을 지원함</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,216 @@
// apps/dm/app/(landing)/reuse/business/globalCase/taiwan/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[대만] 국가 주도의 공업용수 공급"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "대만", href: "/reuse/business/globalCase/taiwan" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/taiwan_01.png", alt: "대만 사례 1" },
]}
/>
{/* 재생 수자원 개발법 제정(2015년제정, 2022개정) */}
<section className="mt-8">
<SectionTitle>재생 수자원 개발법 제정(2015년제정, 2022개정)</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-2 text-base text-gray-600">
<li>
• 목적: 수자원 지속 가능성 향상, 재생 수자원 활용을 통해 물부족 문제 해결
</li>
<li>
• 내용: 재생 수자원 정의·분류, 이용 의무, 이용 제한, 수질 기준 등
</li>
</ul>
<div className="rounded-sm bg-[#e9f0f1] p-8 mt-5">
<ul className="space-y-1 text-2xl text-[#1A1A1A]">
<li>
– 제4조. 물 소비 계획에 따른 재이용수 이용 관련 내용 (일정 비율 재이용수 사용 규정)
</li>
<li>
– 제5조. 재이용수의 운영자, 사용자 관련 내용 (지자체에서 하수 무상공급 제공 가능)
</li>
<li>
– 제7조. 재이용수 이용 범위, 이용량 재이용수 수질 기준 내용 (식품, 제약산업 사용 불가)
</li>
<li>
– 제8조. 재이용수 사업 주체, 책임 등에 관한 내용 (광역인 경우, 정부가 지자체 지정)
</li>
</ul>
</div>
</div>
</section>
<Hr className="my-11" />
{/* 대만정부 하수재이용 사업 시행 */}
<section className="mt-6">
<SectionTitle>대만정부 하수재이용 사업 시행</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑)<br />
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>– 1,618억의 사업비 중 92% 국비, 지방비 8% 투입</li>
<li>– 재이용수는 24km 관로로 타이난 TSMC로 공급</li>
</ul>
</li>
<li>
• 안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능
</li>
<li>
• 공급단가는 수질과 운영조건에 따라 다르게 책정
</li>
</ul>
{/* 테이블 (2단 병렬: 좌/우 데이터) */}
<div className="mt-3">
<div className="overflow-x-auto">
<p className="text-center text-2xl py-2">〈대만 재이용 시설별 재이용수 공급단지〉</p>
<table className="min-w-[900px] w-full text-center text-sm">
<thead className="border">
<tr>
<th className="bg-[#C2CAD2] px-4 py-3 text-black">사업</th>
<th className="bg-sky-600 px-4 py-3 text-white">가격(NTD/㎥)</th>
<th className="bg-green-500 px-4 py-3 text-white">가격(원/㎥)</th>
</tr>
</thead>
<tbody className="bg-white text-[#4F4F4F] border border-t-0">
<tr className="border-t">
<td className="px-4 py-3 bg-white">Fongshan(가오슝)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">23.39</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,038원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Linhai(가오슝)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">19.28</td>
<td className="px-4 py-3 bg-[#E7F4E2]">858원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Anping(타이난)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">38.28</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,699원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Yongkang(타이난)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">23.67</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,050원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Rende(타이난)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">28.26</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,254원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Shuinan(타이중)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">15.00</td>
<td className="px-4 py-3 bg-[#E7F4E2]">668원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Quaitou(가오슝)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">35.44</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,573원</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 풍부한 강수량에도, 지형적 특성으로 인해 수자원 확보에 어려움을 겪고 있으며, 대만 남부지역의 경우 우기와 건기 사이의 강수량 차이가 큼</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 최근 심각한 가뭄이 여러 해에 걸쳐서 발생하고 있으며, 강수량 감소와 함께 대만 정부 주도하에 반도체 산업을 국가 핵심 산업으로 선정하고 반도체 산업 육성 계획을 세움. 그 결과, 2023년 파운드리 시장의 68.3%를 차지하는 반도체 강국으로 성장했으며 이로 인한 공업용수 수요가 크게 증가</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>반도체 산업은 물 소비가 많아, 안정적인 용수공급이 매우 중요한데 현재 19개의 반도체 공장이 물이 부족한 대만 남부지역에 위치함. 2021년 가뭄 발생 당시, 산업단지 기업들도 용수 공급에 어려움을 겪음. 이는 대만 남부 지역의 물 부족, 물 분배 등에 대한 문제를 야기함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만 정부는 산업단지 기업들에게 2023년부터 물에 대한 추가요금 부과를 시작했으며, 농업용수에 대해서는 강력한 억제정책을 시행함. 안정적인 용수 공급을 위해서 정부와 기업에서 자체적으로 재이용수 시설에 대한 투자가 이루어지고 있음.</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 2004년 기후변화로 인한 물 부족 상황을 해결하기 위해 수자원 보호 전략을 승인하고, 2015년 대만 환경부는 재생 수자원 개발법을 발표, 2022년에는 한 차례 개정</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 물 소비량이 많은 산업(high water consuming industries)과 하수처리장(municipal wastewater treatment plants), 두 곳을 재이용수의 주요 공급원으로 계획</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,16 @@
// apps/dm/app/(landing)/reuse/business/layout.tsx
import type { ReactNode } from "react";
import Lnb from "../_components/Lnb";
import { LNB_BUSINESS } from "../_constants/lnb";
export default function BusinessLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb title="사례" items={LNB_BUSINESS} />
<main className="flex-1 px-[100px] pt-[80px]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,93 @@
// apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="경기도 수원시 빗물이용과 중수도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "빗물 재이용 사례" },
{ label: "수원시", href: "/reuse/business/rain/suwon" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/rain/rain_01.png", alt: "수원시 하수 재이용 1" },
{ src: "/images/landing/business/rain/rain_02.png", alt: "수원시 하수 재이용 2" },
{ src: "/images/landing/business/rain/rain_03.png", alt: "수원시 하수 재이용 3" },
{ src: "/images/landing/business/rain/rain_04.png", alt: "수원시 하수 재이용 4" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
경기도 수원시 빗물이용과 중수도
</p>
</section>
<Hr className="my-11" />
{/* 사업 방식 */}
<section className="pb-[200px]">
<SectionTitle>사업 방식</SectionTitle>
<p className="mt-8 text-base text-gray-600">
(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로 비산먼지 저감<br/>
※ 시설 가동중 도로 노면 온도의 7.2 ~ 26.3℃ 감소 효과 확인(‘21.7월, 수원시청).<br/>
다만, 가뭄기나 강우량이 적을 경우 빗물을 원활히 공급하기 다소 곤란<br/><br/>
- (중수도) 수원시청, 장안구청, 월드컵경기장에 빗물 연계 중수도를 화장실·조경용수 등으로 사용하여 수도요금 절감(연 7백~30백만원)
</p>
</section>
</div>
);
}
@@ -0,0 +1,111 @@
// apps/dm/app/(landing)/reuse/business/sewage/bolyeong/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="충남보령 하수 재이용"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/bolyeong_01.png", alt: "보령시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
충남 보령 하수재이용<br/>
: 보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진
</p>
</section>
<Hr className="my-11" />
{/* 사업 방식 */}
<section className="mt-6">
<SectionTitle>사업 방식</SectionTitle>
<p className="mt-8 text-base text-gray-600">
2017년 한국환경공단과 사업대행 위 · 수탁 협약 체결
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
처리공정 : 전처리 UF + 후처리 RO<br/>
시설용량 : 1만m³/일<br/>
관로 : 8.7km<br/>
사업비 : 315억원<br/>
위치: 충청남도 보령시 대청방조제로 322 일원
</p>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<p className="mt-8 text-base text-gray-600">
국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로<br/>
국가전력 기반시설의 안정적 운영 도모
</p>
</section>
</div>
);
}
@@ -0,0 +1,94 @@
// apps/dm/app/(landing)/reuse/business/sewage/cheongju/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="청주시 하수 재이용 시설"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "청주시", href: "/reuse/business/sewage/cheongju" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/cheongju_01.png", alt: "청주시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
청주시 하수 재이용 시설
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="pb-[200px]">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
청주시 하수처리수 재이용 민간투자사업(BTO)<br/>
공급량 : 35,000㎥/일<br/>
공급관로 : 8.3㎞<br/>
사업비 : 622억원<br/>
수요처 : SK하이닉스 등 일반산업단지<br/>
지역 (위치) : 충청북도 청주시 흥덕구 가락리 135-9번지 일원<br/>
처리용량 : 35.000(m³/일)<br/>
적용기술 : 분리막 (UF : 전처리), (RO : 주처리)
</p>
</section>
</div>
);
}
@@ -0,0 +1,148 @@
// apps/dm/app/(landing)/reuse/business/sewage/guri/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="구리시 도로자동청소시스템"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "구리시", href: "/reuse/business/sewage/guri" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/guri_01.png", alt: "구리시 도로자동청소시스템 1" },
{ src: "/images/landing/business/sewage/guri_02.png", alt: "구리시 도로자동청소시스템 2" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
구리시 도로자동청소시스템 설치사업
</p>
</section>
<Hr className="my-11" />
{/* 사업목적 */}
<section className="mt-6">
<SectionTitle>사업목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : 61억원<br/>
위 치 : 구리시 경춘로 및 갈매중앙로 일대 등<br/>
관 로 : 5.8km<br/>
공급계획<br/>
</p>
</section>
<Hr className="my-11" />
{/* 공급계획 */}
<section className="pb-[200px]">
<SectionTitle>공급계획</SectionTitle>
{/* 데이터 테이블 */}
<div className="overflow-x-auto mt-8">
<table className="w-full border border-[#E3E7EA] text-center">
<thead>
<tr>
<th className="bg-[#01ACE5] text-white font-semibold py-4 border border-gray-300">
시설명
</th>
<th className="bg-[#33B5C8] text-white font-semibold py-4 border border-gray-300">
재이용수 용도
</th>
<th className="bg-[#55BCA2] text-white font-semibold py-4 border border-gray-300">
공급량(㎥/일)
</th>
<th className="bg-[#6DBB4F] text-white font-semibold py-4 border border-gray-300">
원수
</th>
</tr>
</thead>
<tbody>
<tr>
<td
className="bg-white py-6 border border-gray-300 text-gray-700"
>
도로자동청소시스템
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
세척살수용수
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
178
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
구리 공공하수처리장 재이용수
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -0,0 +1,173 @@
// apps/dm/app/(landing)/reuse/business/sewage/hwanggu/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="수원시 황구지천 하천유지용수"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/hwanggu_01.png", alt: "수원시 황구지천 하수 재이용 1" },
{ src: "/images/landing/business/sewage/hwanggu_02.png", alt: "수원시 황구지천 하수 재이용 2" },
{ src: "/images/landing/business/sewage/hwanggu_03.png", alt: "수원시 황구지천 하수 재이용 3" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
수원시 황수원시 황구지천 하수처리수 재이용사업
</p>
</section>
<Hr className="my-11" />
{/* 사업목적 */}
<section className="mt-6">
<SectionTitle>사업목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : 110억원<br/>
위 치 : 수원시 권선구 호매실천, 금곡천<br/>
관 로 : 3.9km<br/>
공 급 량 : 25,000㎥/일(하수처리시설 용량: 45,000㎥/일)<br/>
</p>
</section>
<Hr className="my-11" />
{/* 공급계획 */}
<section className="pb-[200px]">
<SectionTitle>공급계획</SectionTitle>
{/* 데이터 테이블 */}
<div className="overflow-x-auto mt-8">
<table className="w-full border border-[#E3E7EA] text-center">
<thead>
<tr>
{/* 구분 */}
<th className="w-1/4 bg-[#E3E7EA] text-black font-semibold py-4 border border-gray-300">
구분
</th>
{/* 공급량 */}
<th className="w-2/4 bg-[#1FA3C6] text-white font-semibold py-4 border border-gray-300">
공급량
</th>
{/* 비고 */}
<th className="w-2/4 bg-[#72B84F] text-white font-semibold py-4 border border-gray-300">
비고
</th>
</tr>
</thead>
<tbody>
<tr>
{/* 좌측 병합 */}
<td
rowSpan={3}
className="bg-white font-medium text-gray-700 border border-gray-300"
>
하천 등 유지용수
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
12,500m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
호매실천 공급
</td>
</tr>
<tr>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
6,250m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
금곡천1 공급
</td>
</tr>
<tr>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
6,250m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
금곡천2 공급
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -0,0 +1,112 @@
// apps/dm/app/(landing)/reuse/business/sewage/paju/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="경기도 파주시 하수 재이용"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "파주시", href: "/reuse/business/sewage/paju" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/paju_01.png", alt: "파주시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
경기도 파주시 하수재이용<br/>
: 운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에 공업용수 공급<br/>
*LG디스플레이(P10공장), 희성전자, 엔비텍 등
</p>
</section>
<Hr className="my-11" />
{/* 사업 배경 및 목적 */}
<section className="mt-6">
<SectionTitle>사업 배경 및 목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
처리공정 : 전처리 UF + 후처리 RO<br/>
공급량 : 4만m³/일<br/>
관로 : 13.6km<br/>
사업비 : 651억원<br/>
위치 : 경기도 파주시 금촌동 714번지 일원<br/>
시설용량 : 41,20m³/일
</p>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<p className="mt-8 text-base text-gray-600">
하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례
</p>
</section>
</div>
);
}
@@ -0,0 +1,101 @@
// apps/dm/app/(landing)/reuse/business/sewage/yeosu/page.tsx
"use client";
import SubPageHeader from "../../../_components/SubPageHeader";
import CaseImageSlider from "../../../_components/ImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="여수시 하수처리수 재이용 민간투자사업(BTO)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "여수시", href: "/reuse/business/sewage/yeosu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/yeosu_01.png", alt: "여수시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
여수시 하수처리수 재이용 민간 투자사업(BTO)
</p>
</section>
<Hr className="my-11" />
{/* 사업 목적 */}
<section className="mt-6">
<SectionTitle>사업 목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
수요자가 원하는 양질의 공업용수를 저렴하게 곰급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선
</p>
</section>
{/* 사업 주요 내용 */}
<section className="pb-[200px]">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : GS칼텍스, YNCC(주) 등 750억원<br/>
시행사 : 여수그린허브(주)<br/>
운영사 : 테크로스<br/>
위치: 여수시 신월로 2844일원 (여수 하수처리시설 부지 내)<br/>
처리공법 : 전처리시설(MF) + 주처리시설(RO)<br/>
시설용량 및 공급량 : 5만m³/일<br/>
관로 : 18.6km
</p>
</section>
</div>
);
}
@@ -0,0 +1,96 @@
// apps/dm/app/(landing)/reuse/cases/greywater/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="중수도(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "중수도(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외) */}
<section className="mt-6">
<SectionTitle>전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외)</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_overall.png" alt="전체 중수도 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 중수공장 및 발전시설 내 중수도 설치 및 이용 현황 */}
<section>
<SectionTitle>공장 및 발전시설 내 중수도 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_factory.png" alt="공장 및 발전시설 내 중수도 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 중수도 건축물별 이용량 현황 */}
<section className="pb-16">
<SectionTitle>중수도 건축물별 이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_building.png" alt="중수도 건축물별 이용량 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 중수도 용도별 이용량 현황 */}
<section className="pb-[200px]">
<SectionTitle>중수도 용도별 이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_usage.png" alt="중수도 용도별 이용량 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,16 @@
// apps/dm/app/(landing)/reuse/cases/layout.tsx
import type { ReactNode } from "react";
import Lnb from "../_components/Lnb";
import { LNB_CASES } from "../_constants/lnb";
export default function CasesLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb title="현황" items={LNB_CASES} />
<main className="flex-1 px-[100px] pt-[80px]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,86 @@
// apps/dm/app/(landing)/reuse/cases/rain/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="빗물이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "빗물이용시설(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section className="mt-6">
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_building.png" alt="빗물이용시설의 건축물별 빗물이용량 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
<section>
<SectionTitle>빗물이용시설의 용도별 분포 (2023년 기준)</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_usage.png" alt="빗물이용시설의 용도별 분포 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section className="pb-[200px]">
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_usage_amount.png" alt="빗물이용시설의 용도별 빗물이용량 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,96 @@
// apps/dm/app/(landing)/reuse/cases/treated/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="하수처리수 재이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "하수처리수 재이용시설(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section className="mt-6">
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_year.png" alt="연도별 하수처리수 재이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_usage.png" alt="용도별 하수처리수 재이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_facility.png" alt="하수처리시설 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section className="pb-[200px]">
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_region.png" alt="시도별 하수처리수 재이용 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,86 @@
// apps/dm/app/(landing)/reuse/intro/greywater/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="중수도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "중수도" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 중수 재이용 소개 */}
<section className="mt-6">
<SectionTitle>중수 재이용 소개</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_intro.png" alt="중수 재이용 소개" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 중수도 설치 의무 대상 */}
<section>
<SectionTitle>중수도 설치 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_mandatory.png" alt="중수도 설치 의무 대상 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 중수도 시설기준 및 관리기준 */}
<section className="pb-[200px]">
<SectionTitle>중수도 시설기준 및 관리기준</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_standard.png" alt="중수도 시설기준 및 관리기준 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,122 @@
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function WaterReuseLawPage() {
return (
<div className="bg-white">
<SubPageHeader
title="물 재이용 법"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "물 재이용 법" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 법제도/계획 수립 타임라인 */}
<section className="mt-6">
<SectionTitle>법제도 · 계획 및 기본계획 수립</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/law_timeline.png" alt="법제도 타임라인" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 제2차 계획 대비 실적 현황 (2컬럼) */}
<section>
<SectionTitle>제 2차 계획 대비 실적 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/performance_left.png" alt="실적 현황 요약" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 국내 관련 현황 */}
<section>
<SectionTitle>국내 관련 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/domestic_textbox.png" alt="국내 관련 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 정책 변천 추이 */}
<section>
<SectionTitle>물 재이용 정책 변천 추이</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/policy_timeline.png" alt="정책 변천 타임라인" />
</div>
</section>
<Hr className="my-11" />
{/* 5) 타 법령과의 관계도 */}
<section>
<SectionTitle>타법령과의 관계도</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rel_note.png" alt="관계도 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 6) 국내 연계 현황 */}
<section className="pb-[200px]">
<SectionTitle>국내 연계 현황</SectionTitle>
<div className="mt-8">
<div className="mx-[20px] mb-8">
<p className="text-[24px] font-semibold mb-2">주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className="text-[#4F4F4F]">국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요<br />
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
</p>
</div>
<ImgCard src="/images/landing/intro/linkage.png" alt="국내 연계 현황" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,16 @@
// apps/dm/app/(landing)/reuse/intro/layout.tsx
import type { ReactNode } from "react";
import Lnb from "../_components/Lnb";
import { LNB_INTRO } from "../_constants/lnb";
export default function IntroLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb title="소개" items={LNB_INTRO} />
<main className="flex-1 px-[100px] pt-[80px]">
{children}
</main>
</div>
);
}
@@ -0,0 +1,76 @@
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function RainwaterReusePage() {
return (
<div className="bg-white">
<SubPageHeader
title="빗물 이용시설"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "빗물 이용시설" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 빗물 재이용 소개 */}
<section className="mt-6">
<SectionTitle>빗물 재이용 소개</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rainreuse_intro.png" alt="빗물 재이용 소개" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 빗물이용시설 설치 관리 법적 의무 대상 */}
<section className="pb-[200px]">
<SectionTitle>빗물이용시설 설치 관리 법적 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rainreuse_mandatory.png" alt="법적 의무 대상 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,76 @@
// apps/dm/app/(landing)/reuse/intro/treated/page.tsx
"use client";
import SubPageHeader from "../../_components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function TreatedWaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="하수처리수"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "하수처리수" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 하수처리수 */}
<section className="mt-6">
<SectionTitle>하수처리수</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/treatedwater_intro.png" alt="하수처리수 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 하수처리수 재이용시설 의무 대상 */}
<section className="pb-[200px]">
<SectionTitle>하수처리수 재이용시설 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/treatedwater_mandatory.png" alt="하수처리수 재이용시설 의무 대상 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,15 @@
// apps/dm/app/(landing)/reuse/layout.tsx
import "./reuse.module.css";
import type { ReactNode } from "react";
import TopNav from "./_components/TopNav";
export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="min-w-[1800px] overflow-x-auto">
<TopNav />
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
</div>
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
// apps/dm/app/(landing)/reuse/page.tsx
"use client";
import TopNav from "./_components/TopNav";
import SideIndicator from "./_components/SideIndicator";
import { useScrollSpy } from "./_hooks/useScrollSpy";
import Hero from "./_sections/Hero";
import IntroCards from "./_sections/IntroCards";
import Cases from "./_sections/Cases";
import ProcessFlow from "./_sections/ProcessFlow";
const LANDING_SECTIONS = [
{ id: "main", label: "Main" },
{
id: "intro",
label: "물 재이용 소개", // ✅ 상단 메뉴용
sideLabel: "소개", // ✅ 사이드 인디게이터용
},
{
id: "cases",
label: "물 재이용 현황", // ✅ 상단 메뉴용
sideLabel: "현황", // ✅ 사이드 인디게이터용
},
{
id: "processflow",
label: "물 재이용 사례", // ✅ 상단 메뉴용
sideLabel: "재이용사례", // ✅ 사이드 인디게이터용
},
];
export default function ReuseLandingPage() {
const activeId = useScrollSpy(LANDING_SECTIONS.map((s) => s.id));
return (
<main className="bg-white">
{/* 헤더 */}
<TopNav />
{/* 사이드 인디게이터 */}
<SideIndicator items={LANDING_SECTIONS} activeId={activeId} />
{/* 컨텐츠 영역 */}
<div className="[&>section]:scroll-mt-[83px]">
{/* 물 재이용 */}
<Hero />
{/* 재이용 소개 */}
<IntroCards />
{/* 재이용 사례 */}
<Cases />
{/* 물 재이용 사업 */}
<ProcessFlow />
</div>
</main>
);
}
@@ -0,0 +1,238 @@
/* apps/dm/app/(landing)/reuse/reuse.module.css */
.hero {
position: relative;
min-height: 100vh;
overflow: hidden;
font-family: "NotoSansKR", sans-serif;
}
.heroBg {
position: absolute;
inset: 0;
background: url("/images/landing/hero_bg.jpg") center/cover no-repeat;
transform: scale(1.02);
}
.heroOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.50);
}
/* ===== Side Indicator (첨부 이미지 스타일) ===== */
.sideIndicator {
position: fixed;
right: 28px;
top: 50%;
transform: translateY(-50%);
z-index: 40;
/* 원형 배지 폭만큼만 */
width: 60px;
/* (디자인처럼) 스크롤 영역 위에 뜨되 클릭 가능 */
pointer-events: none;
}
/* 세로 라인: 연한 하늘색 + 점선(도트) 느낌 */
.sideIndicatorLine {
position: absolute;
left: 50%;
top: -150px;
bottom: -150px;
width: 2px;
transform: translateX(-50%);
background: rgba(46, 177, 235, 0.25);
background-size: 2px 16px;
background-repeat: repeat-y;
z-index: -1;
}
/* 리스트는 가운데 정렬로 수직 배치 */
.sideIndicatorList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 56px; /* 4개 간격(시안에 맞게 조절) */
pointer-events: auto;
}
.sideIndicatorItem {
display: flex;
justify-content: center;
}
/* 공통 버튼 */
.indicatorBtn {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
position: relative;
z-index: 2;
display: grid;
place-items: center;
}
/* ✅ 기본: 작은 점 */
.indicatorDot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(30, 167, 225, 0.55);
border: 1px solid rgba(30, 167, 225, 0.7);
}
/* ✅ 활성: 커지는 큰 원(텍스트 포함) */
.indicatorActive {
width: 83px;
height: 83px;
border-radius: 999px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
/* Main 활성: 흰 배경 + 검정 텍스트 */
.indicatorMainActive {
background: #ffffff;
border: 2px solid rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.75);
}
/* 섹션 활성: 파란 배경 + 흰 텍스트 */
.indicatorSectionActive {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #ffffff;
/* box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18); */
}
/* 활성 텍스트 */
.indicatorText {
font-size: 12px;
font-weight: 700;
line-height: 1.1;
text-align: center;
padding: 0 8px;
word-break: keep-all;
white-space: normal;
}
/* 공통 원형 버튼 */
.sideIndicatorDot {
width: 58px;
height: 58px;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
user-select: none;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease,
border-color 0.15s ease;
/* 라인 위로 올라오게 */
position: relative;
z-index: 1;
pointer-events: auto;
}
.sideIndicatorDotText {
font-size: 12px;
font-weight: 600;
line-height: 1.1;
text-align: center;
padding: 0 8px;
/* 긴 텍스트도 배지 안에서 줄바꿈 가능 */
word-break: keep-all;
white-space: normal;
}
/* Main: 흰색 원형 + 회색 텍스트 */
.sideIndicatorDotMain {
background: #ffffff;
border: 2px solid rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.65);
}
/* 섹션: 파란 원형 + 흰색 텍스트 */
.sideIndicatorDotSection {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #ffffff;
}
/* 활성 섹션: 더 진한 파랑 + 살짝 글로우 */
.sideIndicatorDotActive {
transform: scale(1.02);
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
}
/* hover 효과(이미지 느낌으로 아주 약하게만) */
.sideIndicatorDot:hover {
transform: translateY(-1px);
}
/* 반응형: 모바일에서는 숨김(원하시면 제거) */
@media (max-width: 768px) {
.sideIndicator {
display: none;
}
}
/* 중앙 배지 */
.sideIndicatorBadge {
width: 58px;
height: 58px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 600;
text-align: center;
z-index: 1;
}
/* Main 배지 */
.badgeMain {
background: #fff;
border: 2px solid rgba(0,0,0,.15);
color: rgba(0,0,0,.65);
}
/* Section 배지 */
.badgeSection {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #fff;
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
}
/* 작은 점 */
.sideIndicatorDotSmall {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(30,167,225,.6);
}
/* 점 그룹 */
.sideIndicatorDotsTop,
.sideIndicatorDotsBottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.sideIndicatorDotsTop { margin-bottom: 18px; }
.sideIndicatorDotsBottom { margin-top: 22px; }
@@ -0,0 +1,483 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import SubPageHeader from "@/components/SubPageHeader";
import { useDmStore } from "@/app/store";
import { fetchType, getFetch, postFetch, putFetch } from "@/components/ui/api";
import { getCookie } from "@common/components/lib";
type Option = { value: string; label: string };
type SessionUser = {
dmdInfoId?: string;
dmdCoNm?: string;
daddr?: string;
telno?: string;
ctpvCd?: string;
sggCd?: string;
rgtr?: string;
[key: string]: unknown;
};
const TOKEN_ID_COOKIE = "token-id";
const FALLBACK_UUID = "c475824c-0e82-4a06-a282-2b55a20df265";
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function FormRow({
label,
required,
children,
}: {
label: ReactNode;
required?: boolean;
children: ReactNode;
}) {
return (
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6">
<div className="flex items-center gap-1 text-lg font-semibold text-neutral-800">
<span>{label}</span>
{required ? <span className="text-red-500">*</span> : null}
</div>
</div>
<div className="col-span-12 lg:col-span-10 px-6 py-6">{children}</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
className = "",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
type={type}
value={value}
disabled={disabled}
onChange={(e) => 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: string;
onChange: (v: string) => void;
options: Option[];
className?: string;
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800",
"focus:border-neutral-400 focus:outline-none",
className,
].join(" ")}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
export default function DemandPlaceRegisterPage() {
const { Common, initializeCommonData } = useDmStore((state) => state);
const { selectSidoData, selectSigunData } = Common;
const [editMode, setEditMode] = useState(false);
const [sessionUser, setSessionUser] = useState<SessionUser | null>(null);
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("");
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
useEffect(() => {
if (typeof window === "undefined") return;
const rawUser = sessionStorage.getItem("user");
if (!rawUser) {
setEditMode(false);
setSessionUser(null);
return;
}
try {
const user = JSON.parse(rawUser) as SessionUser;
setSessionUser(user);
setEditMode(true);
const fullId = String(user.dmdInfoId ?? "").trim();
const bizDigits = fullId.slice(0, 10);
const workerDigits = fullId.slice(10, 14);
setBizNo(bizDigits);
if (workerDigits && workerDigits !== "0000") {
setNoWorkerNo(false);
setWorkerNo(workerDigits);
} else {
setNoWorkerNo(true);
setWorkerNo("");
}
setCompany(String(user.dmdCoNm ?? ""));
setAddr1(String(user.daddr ?? ""));
setAddr2("");
setTel(String(user.telno ?? "").replace(/\D/g, ""));
setSido(String(user.ctpvCd ?? ""));
setSigungu(String(user.sggCd ?? ""));
} catch {
setEditMode(false);
setSessionUser(null);
}
}, []);
const sidoOptions = useMemo<Option[]>(() => {
const mapped = selectSidoData
.filter((item) => item.val !== "0")
.map((item) => ({ value: item.val, label: item.text }));
return [{ value: "", label: "선택" }, ...mapped];
}, [selectSidoData]);
const sigunguOptions = useMemo<Option[]>(() => {
if (!sido) return [{ value: "", label: "선택" }];
const mapped = selectSigunData
.filter((item) => item.val.substring(0, 2) === sido)
.map((item) => ({ value: item.val, label: item.text }));
return [{ value: "", label: "선택" }, ...mapped];
}, [sido, selectSigunData]);
const formatBusinessNumber = (value: string) => {
const digitsOnly = value.replace(/\D/g, "").slice(0, 10);
if (digitsOnly.length <= 3) return digitsOnly;
if (digitsOnly.length <= 5) {
return `${digitsOnly.slice(0, 3)}-${digitsOnly.slice(3)}`;
}
return `${digitsOnly.slice(0, 3)}-${digitsOnly.slice(3, 5)}-${digitsOnly.slice(5)}`;
};
const checkDuplicate = async (fullDmdInfoId: string) => {
try {
const res = await getFetch(fetchType.WR, `/reuseFc/request/${fullDmdInfoId}`);
const responseData = (res?.data ?? res) as { dmdInfoId?: string } | undefined;
return Boolean(responseData?.dmdInfoId);
} catch {
return false;
}
};
const onSubmit = async () => {
const bizDigits = bizNo.replace(/\D/g, "").slice(0, 10);
const workerDigits = workerNo.replace(/\D/g, "").slice(0, 4);
const telDigits = tel.replace(/\D/g, "");
const password = pw.trim();
const passwordConfirm = pw2.trim();
if (!sido || sido === "0") return alert("시도/시군구를 선택해 주세요.");
if (!sigungu || sigungu === "0") return alert("시도/시군구를 선택해 주세요.");
if (!company.trim()) return alert("회사명을 입력해 주세요.");
if (company.trim().length > 20) return alert("회사명은 20자 이내로 입력해주세요.");
if (!bizDigits) return alert("사업자등록번호를 입력해 주세요.");
if (!/^\d{10}$/.test(bizDigits)) {
return alert("사업자등록번호는 10자리 숫자만 입력 가능합니다.");
}
if (!noWorkerNo) {
if (!workerDigits) return alert("종사업자등록번호를 입력해 주세요.");
if (!/^\d{4}$/.test(workerDigits)) {
return alert("종사업자등록번호는 4자리 숫자만 입력 가능합니다.");
}
}
const mergedAddress = [addr1.trim(), addr2.trim()].filter(Boolean).join(" ");
if (!mergedAddress) return alert("사업자주소를 입력해 주세요.");
if (mergedAddress.length > 40) return alert("주소는 40자 이내로 입력해주세요.");
if (!telDigits) return alert("전화번호를 입력해 주세요.");
if (!/^\d{8,11}$/.test(telDigits)) {
return alert("전화번호는 8~11자리 숫자만 입력 가능합니다.");
}
if (!editMode) {
if (!password) return alert("비밀번호를 입력해 주세요.");
if (password.length < 8 || password.length > 16) {
return alert("비밀번호는 8자 이상 16자 이내로 입력해주세요.");
}
if (!passwordConfirm) return alert("비밀번호 확인을 입력해 주세요.");
if (password !== passwordConfirm) return alert("비밀번호가 일치하지 않습니다.");
} else if ((password || passwordConfirm) && password !== passwordConfirm) {
return alert("비밀번호가 일치하지 않습니다.");
}
const fullDmdInfoId = bizDigits + (noWorkerNo ? "0000" : workerDigits);
if (!editMode) {
const isDuplicate = await checkDuplicate(fullDmdInfoId);
if (isDuplicate) return alert("이미 등록된 사업자등록번호입니다.");
}
const myUUID = getCookie(TOKEN_ID_COOKIE) || FALLBACK_UUID;
const payload: Record<string, unknown> = {
ctpvCd: sido,
sggCd: sigungu,
dmdCoNm: company.trim(),
dmdInfoId: fullDmdInfoId,
bzmnNo: bizDigits,
daddr: mergedAddress,
telno: telDigits,
rgtr: (sessionUser?.rgtr as string) || myUUID,
};
if (password) {
payload.dmdAplyPswd = password;
}
try {
if (editMode) {
payload.mdfr = myUUID;
await putFetch("/reuseFc/request", payload);
if (typeof window !== "undefined") {
const nextSessionUser = {
...(sessionUser ?? {}),
...payload,
};
delete nextSessionUser.dmdAplyPswd;
sessionStorage.setItem("user", JSON.stringify(nextSessionUser));
}
alert("수요처 수정 완료!");
} else {
await postFetch("/reuseFc/request", payload);
alert("수요처 등록 완료!");
}
setPw("");
setPw2("");
} catch (error) {
console.error("Failed to submit demand place", error);
alert(editMode ? "수요처 수정에 실패하였습니다." : "수요처 등록에 실패하였습니다.");
}
};
const pageTitle = editMode ? "수요처수정" : "수요처등록";
return (
<main className="bg-white">
<SubPageHeader
title={pageTitle}
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: pageTitle },
]}
/>
<div className="border border-neutral-200 bg-white">
<FormRow label="시도" required>
<SelectBox
value={sido}
onChange={(v) => {
setSido(v);
setSigungu("");
}}
options={sidoOptions}
/>
</FormRow>
<Divider />
<FormRow label="시군구" required>
<select
value={sigungu}
onChange={(e) => setSigungu(e.target.value)}
className="h-11 w-[220px] rounded border border-neutral-300 bg-white px-3 text-sm text-neutral-800 focus:border-neutral-400 focus:outline-none"
>
{sigunguOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</FormRow>
<Divider />
<FormRow label="회사명" required>
<div className="max-w-[420px]">
<TextInput
value={company}
onChange={setCompany}
placeholder="회사명을 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="사업자등록번호" required>
<div className="max-w-[420px]">
<TextInput
value={formatBusinessNumber(bizNo)}
onChange={(v) => setBizNo(v.replace(/\D/g, "").slice(0, 10))}
placeholder="사업자 등록번호를 입력해주세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="종사자등록번호">
<div className="flex flex-wrap items-center gap-4">
<div className="w-[420px] max-w-full">
<TextInput
value={workerNo}
onChange={(v) => setWorkerNo(v.replace(/\D/g, "").slice(0, 4))}
placeholder="종사자등록번호를 입력해주세요."
disabled={noWorkerNo}
/>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noWorkerNo}
onChange={(e) => {
setNoWorkerNo(e.target.checked);
if (e.target.checked) setWorkerNo("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
해당없음
</label>
</div>
</FormRow>
<Divider />
<FormRow label="사업자주소" required>
<div className="max-w-[420px] space-y-2">
<div className="flex gap-2">
<div className="w-[180px]">
<TextInput value={zip} onChange={setZip} placeholder="우편번호" />
</div>
<button
type="button"
onClick={() => alert("우편번호 검색(샘플)")}
className="h-11 rounded bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
>
우편번호 검색
</button>
</div>
<TextInput value={addr1} onChange={setAddr1} placeholder="주소" />
<TextInput
value={addr2}
onChange={setAddr2}
placeholder="상세 주소를 입력하세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="전화번호" required>
<div className="max-w-[420px]">
<TextInput
value={tel}
onChange={(v) => setTel(v.replace(/\D/g, "").slice(0, 11))}
placeholder="전화번호를 입력하세요."
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호" required={!editMode}>
<div className="max-w-[420px]">
<TextInput
value={pw}
onChange={setPw}
placeholder="비밀번호를 입력하세요."
type="password"
/>
</div>
</FormRow>
<Divider />
<FormRow label="비밀번호 확인" required={!editMode}>
<div className="max-w-[420px]">
<TextInput
value={pw2}
onChange={setPw2}
placeholder="비밀번호를 한번 더 입력하세요."
type="password"
/>
</div>
</FormRow>
</div>
<div className="flex justify-center py-12">
<button
type="button"
onClick={onSubmit}
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
>
{editMode ? "수요처 수정" : "수요처 등록"}
</button>
</div>
</main>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import Lnb from "@/components/Lnb";
export default function ApplyLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb />
<main className="flex-1 px-[100px] pt-[80px]">{children}</main>
</div>
);
}
@@ -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 (
<div className="fixed inset-0 z-[120]">
{/* Backdrop */}
<button
type="button"
aria-label="닫기"
onClick={onClose}
className="absolute inset-0 bg-black/40"
/>
{/* Dialog */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-[1100px] rounded-[18px] bg-white shadow-2xl overflow-hidden"
>
{/* Header */}
<div className="relative px-10 pt-9 pb-6">
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="absolute right-7 top-7 h-10 w-10 grid place-items-center rounded-full hover:bg-gray-100"
>
<span className="text-2xl leading-none">×</span>
</button>
<h2
id={titleId}
className="text-left text-[28px] font-bold tracking-tight text-gray-900"
>
{title}
</h2>
<div className="mt-5 h-px w-full bg-gray-200" />
</div>
{/* Body (스크롤 영역) */}
<div className="px-10 pb-10">
<div className="max-h-[70vh] overflow-auto bg-white">
{/* 이미지가 길어도 여기서 스크롤 */}
<Image
src={imageSrc}
alt="수질 기준 참고"
width={851}
height={2334}
className="block mx-auto max-w-full h-auto"
priority
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -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<string>(today);
const [reqQty, setReqQty] = useState<string>("");
const [usePurpose, setUsePurpose] = useState<string>("");
const [hopePrice, setHopePrice] = useState<string>("");
// 요구수질
const [coliform, setColiform] = useState<string>("");
const [noDetect, setNoDetect] = useState<boolean>(false);
const [tn, setTn] = useState<string>("");
const [residualCl, setResidualCl] = useState<string>("");
const [tp, setTp] = useState<string>("");
const [turbidity, setTurbidity] = useState<string>("");
const [ph, setPh] = useState<PHRange>({ min: "", max: "" });
const [bod, setBod] = useState<string>("");
const [chloride, setChloride] = useState<string>("");
const [odor, setOdor] = useState<string>("불쾌하지 않을 것");
const [ec, setEc] = useState<string>("");
const [color, setColor] = useState<string>("");
const [remark, setRemark] = useState<string>("");
// 공급처 후보지 선택(필수아님)
const [cand1, setCand1] = useState("");
const [cand2, setCand2] = useState("");
const [cand3, setCand3] = useState("");
return (
<>
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요정보등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요정보등록" },
]}
/>
{/* 컨텐츠 영역 */}
<div>
{/* 기본정보 테이블 */}
<div className="border border-neutral-200 bg-white">
<FormRow label="요구시기" required>
<DateInput value={reqDate} onChange={setReqDate} />
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<TextInput value={reqQty} onChange={setReqQty} placeholder="" />
</FormRow>
<Divider />
<FormRow label="사용용도" required>
<TextInput value={usePurpose} onChange={setUsePurpose} />
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
{/* 요구수질 섹션 타이틀 바 */}
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 inline-flex justify-center items-center gap-3 px-6 py-6 text-center">
<span className="text-lg font-semibold text-neutral-800">요구수질</span>
<button
type="button"
onClick={() => setOpenRef(true)}
className="rounded border-[2px] border-[#DE3412] px-2 py-3 text-[15px] font-medium text-[#4F4F4F] hover:bg-orange-50"
>
수질기준 참고
</button>
{/* 수질기준참고 모달 */}
<WaterQualityRefModal
open={openRef}
onClose={() => setOpenRef(false)}
imageSrc="/images/landing/water_quality_ref_1.png"
/>
</div>
</div>
{/* 요구수질 테이블 */}
<div className="border border-neutral-200 bg-white">
{/* 1행 */}
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftRequired
leftControl={
<div className="flex items-center gap-4">
<TextInput
value={coliform}
onChange={setColiform}
disabled={noDetect}
className={noDetect ? "bg-neutral-100 text-neutral-400" : ""}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noDetect}
onChange={(e) => {
setNoDetect(e.target.checked);
if (e.target.checked) setColiform("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
}
rightLabel="총질소(T-N)(mg/L)"
rightControl={
<InlineSuffixInput value={tn} onChange={setTn} suffix="이하" />
}
/>
<Divider />
{/* 2행 */}
<QualityRow
leftLabel="결합잔류염소(mg/L)"
leftRequired
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
rightLabel="총인(T-P)(mg/L)"
rightControl={
<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />
}
/>
<Divider />
{/* 3행 */}
<QualityRow
leftLabel="탁도(NTU)"
leftRequired
leftControl={
<InlineSuffixInput value={turbidity} onChange={setTurbidity} suffix="이하" />
}
rightLabel="수소이온농도(pH)"
rightControl={
<div className="flex items-center gap-2">
<TextInput
value={ph.min}
onChange={(v) => setPh((p) => ({ ...p, min: v }))}
className="w-32"
/>
<span className="text-neutral-500">~</span>
<TextInput
value={ph.max}
onChange={(v) => setPh((p) => ({ ...p, max: v }))}
className="w-32"
/>
</div>
}
/>
<Divider />
{/* 4행 */}
<QualityRow
leftLabel={
<>
생물화학적산소요구량
<br />
(BOD)(mg/L)
</>
}
leftRequired
leftControl={<InlineSuffixInput value={bod} onChange={setBod} suffix="이하" />}
rightLabel="염화물(mgCL/L)"
rightControl={<InlineSuffixInput value={chloride} onChange={setChloride} suffix="이하" />}
/>
<Divider />
{/* 5행 */}
<QualityRow
leftLabel="냄새"
leftRequired
leftControl={
<select
value={odor}
onChange={(e) => setOdor(e.target.value)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
}
rightLabel="전기전도도(µs/cm)"
rightControl={<InlineSuffixInput value={ec} onChange={setEc} suffix="이하" />}
/>
<Divider />
{/* 6행 */}
<QualityRow
leftLabel="색도(도)"
leftRequired
leftControl={<InlineSuffixInput value={color} onChange={setColor} suffix="이하" />}
rightLabel="비고"
rightControl={
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="h-11 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500"
/>
}
/>
<Divider />
{/* 후보지 선택 */}
<div className="grid grid-cols-12">
<LabelCell>
공급처 후보지 선택
<br />
(필수아님)
</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-4">
<TextInput value={cand1} onChange={setCand1} className="max-w-[220px]" />
<TextInput value={cand2} onChange={setCand2} className="max-w-[220px]" />
<TextInput value={cand3} onChange={setCand3} className="max-w-[220px]" />
<div className="flex items-center gap-3 xl:ml-2">
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => {
// TODO: 후보지 조회 모달/검색 연결
console.log("후보지 조회", { cand1, cand2, cand3 });
}}
>
후보지 조회
</button>
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-600"
onClick={() => {
// TODO: 유사사례 연결
console.log("유사사례");
}}
>
유사사례
</button>
</div>
</div>
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="flex justify-center py-12">
<button
type="button"
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
onClick={() => {
// TODO: 저장 처리
console.log("수요정보 등록");
}}
>
수요정보 등록
</button>
</div>
</div>
</main>
</>
);
}
/* ----------------------------- UI helpers ----------------------------- */
function Divider() {
return <div className="h-px w-full bg-neutral-200" />;
}
function LabelCell({
children,
required,
}: {
children: React.ReactNode;
required?: boolean;
}) {
return (
<div className="col-span-12 md:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
{children}
{required ? <span className="text-red-500">*</span> : null}
</span>
</div>
);
}
function FormRow({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
<LabelCell required={required}>{label}</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">{children}</div>
</div>
);
}
function QualityRow({
leftLabel,
leftRequired,
leftControl,
rightLabel,
rightControl,
}: {
leftLabel: React.ReactNode;
leftRequired?: boolean;
leftControl: React.ReactNode;
rightLabel: React.ReactNode;
rightControl: React.ReactNode;
}) {
return (
<div className="grid grid-cols-12">
{/* 좌측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
<span className="leading-6">{leftLabel}</span>
{leftRequired ? <span className="text-red-500">*</span> : null}
</span>
</div>
{/* 좌측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{leftControl}</div>
{/* 우측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<span className="leading-6">{rightLabel}</span>
</div>
{/* 우측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{rightControl}</div>
</div>
);
}
function TextInput({
value,
onChange,
placeholder,
disabled,
className,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={[
"h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none",
"focus:border-neutral-500",
disabled ? "cursor-not-allowed" : "",
className ?? "",
].join(" ")}
/>
);
}
function InlineSuffixInput({
value,
onChange,
suffix,
}: {
value: string;
onChange: (v: string) => void;
suffix: string;
}) {
return (
<div className="flex items-center gap-3">
<TextInput value={value} onChange={onChange} className="max-w-[200px]" />
<span className="text-sm text-neutral-600">{suffix}</span>
</div>
);
}
function DateInput({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
const openPicker = () => {
const el = ref.current;
if (!el) return;
// Chrome/Edge 등 지원
if (typeof (el as any).showPicker === "function") {
(el as any).showPicker();
return;
}
// fallback (일부 브라우저)
el.focus();
el.click();
};
return (
<div className="relative w-full max-w-[200px]">
<input
ref={ref}
type="date"
value={value}
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
"[&::-webkit-calendar-picker-indicator]:opacity-0",
"[&::-webkit-calendar-picker-indicator]:absolute",
"[&::-webkit-calendar-picker-indicator]:right-0",
"[&::-webkit-calendar-picker-indicator]:w-10",
"[&::-webkit-calendar-picker-indicator]:h-full",
"[&::-webkit-calendar-picker-indicator]:cursor-pointer",
].join(" ")}
/>
{/* ✅ 클릭 가능한 아이콘 버튼 */}
<button
type="button"
onClick={openPicker}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700"
aria-label="달력 열기"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
);
}
@@ -0,0 +1,338 @@
// apps/dm/app/(landing)/reuse/apply/status/page.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import SubPageHeader from "@/components/SubPageHeader";
type WaterDetail = {
toc: string;
bdo: string;
tn: string;
tp: string;
cl: string;
conductivity: string;
coliform: string;
turbidity: string;
odor: string;
color: string;
ph: string;
note: string;
};
type Row = {
id: number;
sido: string;
sigungu: string;
company: string;
address: string;
phone: string;
date: string;
status: "진행중" | "진행완료";
detail: {
reqDate: string;
reqQty: string;
usePurpose: string;
price: string;
water: WaterDetail;
candidates: [string, string, string];
};
};
const MOCK: Row[] = [
{
id: 1,
sido: "서울특별시",
sigungu: "금천구",
company: "서울워터솔루션(주)",
address: "서울특별시 금천구 가산디지털2로 119, 24층(가산동)",
phone: "010-0000-0000",
date: "2025-01-14",
status: "진행중",
detail: {
reqDate: "2025-02",
reqQty: "1,200",
usePurpose: "공업용수",
price: "350",
water: {
toc: "4",
bdo: "3",
tn: "10",
tp: "1.0",
cl: "120",
conductivity: "800",
coliform: "0",
turbidity: "2",
odor: "무",
color: "무",
ph: "7.2",
note: "-",
},
candidates: ["후보지1", "후보지2", "후보지3"],
},
},
{
id: 2,
sido: "제주특별자치도",
sigungu: "서귀포",
company: "회사명이 길어도괜찮지",
address: "제주특별자치도 서귀포시 천제연로 156, 3층 2호(색달동 1077호)",
phone: "010-0000-0000",
date: "2025-01-14",
status: "진행완료",
detail: {
reqDate: "2025-03",
reqQty: "800",
usePurpose: "조경용수",
price: "300",
water: {
toc: "5",
bdo: "4",
tn: "12",
tp: "1.2",
cl: "140",
conductivity: "900",
coliform: "0",
turbidity: "3",
odor: "무",
color: "무",
ph: "7.0",
note: "현장 협의",
},
candidates: ["후보지1", "후보지2", "후보지3"],
},
},
];
function StatusBadge({ v }: { v: Row["status"] }) {
const cls = v === "진행중" ? "text-red-600" : "text-lime-600";
return <span className={["font-medium", cls].join(" ")}>{v}</span>;
}
type TopField = { label: string; value: string; required?: boolean };
type PairField = { label: string; value: string };
export default function DemandStatusPage() {
const rows = useMemo(() => MOCK, []);
// ✅ rows가 빈 경우도 대비 (런타임 오류 방지)
const [selectedId, setSelectedId] = useState<number | null>(rows[0]?.id ?? null);
// ✅ 상세영역 스크롤 이동용
const detailRef = useRef<HTMLDivElement | null>(null);
// ✅ selected 계산 안정화
const selected = useMemo(() => {
if (!rows.length) return null;
const found = rows.find((r) => r.id === selectedId);
return found ?? rows[0];
}, [rows, selectedId]);
// ✅ rows가 바뀌었는데 selectedId가 null이면 자동 첫번째 선택
useEffect(() => {
const first = rows[0];
if (!first) return; // ✅ rows가 비어있으면 종료 (TS + 런타임 안전)
if (selectedId == null) setSelectedId(first.id);
}, [rows, selectedId]);
// ✅ 상단 2행(요구시기/요구수량/사용용도/희망단가) - TS 안전하게 object로 구성
const topFields: TopField[] = useMemo(() => {
if (!selected) return [];
return [
{ label: "요구시기", value: selected.detail.reqDate, required: true },
{ label: "요구수량(㎡/d)", value: selected.detail.reqQty },
{ label: "사용용도", value: selected.detail.usePurpose },
{ label: "희망단가(원)", value: selected.detail.price },
];
}, [selected]);
// ✅ 수질 항목 - TS 안전
const waterFields: PairField[] = useMemo(() => {
if (!selected) return [];
const w = selected.detail.water;
return [
{ label: "총대장균군수(개/100mℓ)", value: w.coliform },
{ label: "결합잔류염소(mg/L)", value: "-" },
{ label: "탁도(NTU)", value: w.turbidity },
{ label: "생물화학적산소요구량(BOD)(mg/L)", value: w.bdo },
{ label: "냄새", value: w.odor },
{ label: "색도(도)", value: w.color },
{ label: "총질소(T-N)(mg/L)", value: w.tn },
{ label: "총인(T-P)(mg/L)", value: w.tp },
{ label: "수소이온농도(pH)", value: w.ph },
{ label: "염화물(mgCL/L)", value: w.cl },
{ label: "전기전도도(µs/cm)", value: w.conductivity },
{ label: "비고", value: w.note },
];
}, [selected]);
const onSelectRow = (id: number) => {
setSelectedId(id);
// ✅ “행 클릭 → 아래 상세로 보여짐” 체감 확실히: 상세영역으로 이동
// (사용자가 이미 아래를 보고 있다면 거의 움직이지 않음)
requestAnimationFrame(() => {
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
};
return (
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요신청현황"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요신청현황" },
]}
hideFooterLine
hideGuideText
/>
{/* 컨텐츠 영역 */}
<div>
{/* 리스트 테이블 */}
<section className="mt-6">
<div className="text-2xl font-bold text-slate-700">수요정보 신청현황</div>
<div className="mt-3 border border-neutral-200">
<table className="w-full table-fixed">
<thead className="bg-[#E7F4E2]">
<tr className="text-sm text-slate-700">
<th className="w-[60px] px-3 py-3 text-center">NO.</th>
<th className="w-[120px] px-3 py-3 text-center">시도</th>
<th className="w-[120px] px-3 py-3 text-center">시군구</th>
<th className="w-[180px] px-3 py-3 text-center">회사명</th>
<th className="px-3 py-3 text-center">공장주소</th>
<th className="w-[140px] px-3 py-3 text-center">전화번호</th>
<th className="w-[120px] px-3 py-3 text-center">신청일자</th>
<th className="w-[110px] px-3 py-3 text-center">진행상태</th>
</tr>
</thead>
<tbody className="bg-white text-base text-slate-700">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-10 text-center text-slate-400">
조회된 신청내역이 없습니다.
</td>
</tr>
) : (
rows.map((r) => {
const active = r.id === selected?.id;
return (
<tr
key={r.id}
role="button"
tabIndex={0}
onClick={() => onSelectRow(r.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelectRow(r.id);
}}
className={[
"border-t border-neutral-200 outline-none",
"cursor-pointer",
active ? "bg-slate-50" : "hover:bg-slate-50",
"focus-visible:ring-2 focus-visible:ring-slate-300",
].join(" ")}
aria-selected={active}
>
<td className="px-3 py-3 text-center">{r.id}</td>
<td className="px-3 py-3 text-center">{r.sido}</td>
<td className="px-3 py-3 text-center">{r.sigungu}</td>
<td className="px-3 py-3 text-center">{r.company}</td>
<td className="px-3 py-3 text-center">{r.address}</td>
<td className="px-3 py-3 text-center">{r.phone}</td>
<td className="px-3 py-3 text-center">{r.date}</td>
<td className="px-3 py-3 text-center">
<StatusBadge v={r.status} />
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</section>
{/* 상세현황 */}
<section className="mt-10 pb-16" ref={detailRef}>
<div className="text-2xl font-bold text-slate-700">수요신청 상세현황</div>
{!selected ? (
<div className="mt-3 border border-neutral-200 px-6 py-10 text-center text-sm text-slate-400">
상세 정보를 표시할 신청건이 없습니다.
</div>
) : (
<div className="mt-3 border border-neutral-200">
<div className="bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수량
</div>
<div className="grid grid-cols-12 text-sm">
{/* 상단 2행 */}
{topFields.map((f) => (
<div
key={f.label}
className="col-span-12 md:col-span-6 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-4 bg-sky-50 px-4 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{f.label}
{f.required ? <span className="ml-1 text-red-500">*</span> : null}
</div>
<div className="col-span-8 px-4 py-4 flex items-center justify-center text-center">
{f.value}
</div>
</div>
))}
{/* 수질 */}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
요구수질
</div>
{waterFields.map((f) => (
<div
key={f.label}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
{f.label}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{f.value}
</div>
</div>
))}
{/* 공급처 후보지 */}
<div className="col-span-12 bg-sky-50 px-4 py-3 text-sm font-semibold text-slate-700 border-b border-neutral-200">
공급처 후보지
</div>
{selected.detail.candidates.map((v, i) => (
<div
key={`candidate-${i}`}
className="col-span-12 md:col-span-4 grid grid-cols-12 border-b md:border-b-0 border-neutral-200"
>
<div className="col-span-5 bg-sky-50 px-3 py-4 flex items-center justify-center text-center font-semibold text-slate-700">
후보지{i + 1}
</div>
<div className="col-span-7 px-3 py-4 flex items-center justify-center text-center">
{v}
</div>
</div>
))}
</div>
</div>
)}
</section>
</div>
</main>
);
}
@@ -0,0 +1,151 @@
// apps/dm/app/(landing)/reuse/business/globalCase/australia/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
// type ImgCardProps = {
// src: string;
// alt: string;
// };
// function ImgCard({ src, alt }: ImgCardProps) {
// return (
// <div className="w-full overflow-x-auto">
// <img
// src={src}
// alt={alt}
// className="block mx-auto max-w-none"
// />
// </div>
// );
// }
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "호주", href: "/reuse/business/globalCase/australia" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/australia_01.png", alt: "호주 사례 1" },
]}
/>
{/* 생활용수로 재이용수의 적극 활용 */}
<section className="mt-8">
<SectionTitle>생활용수로 재이용수의 적극 활용</SectionTitle>
<div className="px-4 mt-6">
<ul className="space-y-4 text-base text-[#4F4F4F]">
<li>
– 재이용수를 공원, 골프장 등의 관개용수로 공급(초록색 지역)
</li>
<li>
– Dual pipe로 재이용수를 특정 주거단지로 공급(보라색 지역)
</li>
<li>
– 현재 7,000여 가구가 재이용수를 사용
</li>
<li>
– 2030년까지 43,000가구로 확대 예정
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 호주 재이용수 광역 활용(퀸즈랜드주 브리즈번) */}
<section className="mt-6">
<SectionTitle>호주 재이용수 광역 활용(퀸즈랜드주 브리즈번)</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망
</li>
<li>
• 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급<br />
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>– 1,618억의 사업비 중 92% 국비, 지방비 8% 투입</li>
<li>– 재이용수는 24km 관로로 타이난 TSMC로 공급</li>
</ul>
</li>
<li>
• 12개 댐, 32개 수처리 시설, 3개 재이용시설, 담수화 플랜트 1개, 저수지 28개와 600km 이상의 물 공급 관로를 건설
</li>
<li>
• 지역적으로 강수량이 고르지 않아, 댐의 저수율이 불균등할 때 지역의 물 공급을 보충하는 중요한 역할을 수행
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주는 2000~2009년 강수량 감소로 밀레니엄 가뭄이 발생하여 심각한 물 부족 현상이 발생함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주는 해안 도시에 인구가 밀집하여 거주하고 있으며 인구 증가율이 타 선진국 대비 높아, 향후 도시에 공급할 수자원 확보가 필요함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주정부는 1990년대부터 하·폐수가 방류될 경우의 환경 피해에 대해 인식하기 시작하고 배출규제와 위원회를 구성하기 시작</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>연방정부는 주에 인센티브를 주는 방식으로 물 정책을 장려하고 토지소유권과 물 권리를 구분하여 거래를 허용하고 물 재이용 활성화를 위한 Water Smart Australia 프로그램을 시행</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>호주의 용수공급 체계는 영국과 미국의 특성들이 존재하지만, 주의 특성과 주 정부의 정책 방향에 따라 주마다도 많은 차이가 있음. 특히 대부분의 주에서 수 자원 개발 기능과 수도 공급의 기능을 분리하여 주정부<br/>산하 기관에서 운영하는게 특징</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,142 @@
// apps/dm/app/(landing)/reuse/business/globalCase/eu/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
// type ImgCardProps = {
// src: string;
// alt: string;
// };
// function ImgCard({ src, alt }: ImgCardProps) {
// return (
// <div className="w-full overflow-x-auto">
// <img
// src={src}
// alt={alt}
// className="block mx-auto max-w-none"
// />
// </div>
// );
// }
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[EU] 법률을 제정, EU국가에서 물 재이용 유도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "EU", href: "/reuse/business/globalCase/eu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/eu_01.png", alt: "EU 사례 1" },
]}
/>
{/* 물 재이용에 대한 법률을 제정(2020 제정, 2023 시행) */}
<section className="mt-8">
<SectionTitle>물 재이용에 대한 법률을 제정(2020 제정, 2023 시행)</SectionTitle>
<div className="px-4 mt-6">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741)
</li>
<li>
• 2023년부터 모든 EU국가에서 시행
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 유럽연합 회원국 물 재이용 지원정책 */}
<section className="mt-6">
<SectionTitle>유럽연합 회원국 물 재이용 지원정책</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 유럽연합 차원에서 다양한 물 재이용 프로그램 자금 지원<br />
<ul className="list-inside space-y-4 text-[#767676] text-sm ml-4 mt-2">
<li>– 2021년부터 3년간 Life RECYCLIO 프로그램 지원 → 세부내용 물 재이용 프로그램<br/>
<ul className="list-inside space-y-2 text-[#767676] ml-4 mt-2">
<li>1. 환경 관련 사업에 자금을 지원하는 Life 프로그램의 일환으로 80만 유로를 지원</li>
<li>2. 식품·농업 분야 물 재이용 확대를 위해 기술 이전 및 평가 실시</li>
</ul>
</li>
<li>– 물 재이용 정책 수행을 위해 BOOST-INN 180만 유로를 지원<br/>
<ul className="list-inside space-y-2 text-[#767676] ml-4 mt-2">
<li> *프랑스, 이탈리아, 스페인 등 유럽 선진국 솔루션 공유</li>
</ul>
</li>
</ul>
</li>
<li>
• EU산하 JRC(공동연구센터)에서 물 재이용 기술제공 지원<br />
<ul className="list-inside space-y-2 text-[#767676] text-sm ml-4 mt-2">
<li>‘물 재이용 회원 관리 실무 커뮤니티(Water Reuse Management Community of Practice)’ 시범사업 수행</li>
<li>유럽 전역에 성공적인 모델 활용 및 혁신적인 물 재이용 솔루션을 확장할 수 있는 기술을 제공</li>
</ul>
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>2019년 기준 EU 인구의 38%가 물 부족의 영향을 받을 것으로 예상됨. 이 흐름대로면 2030년까지 유럽 유역 전반에 물 부족 문제가 발생하게 될 것으로 예상. 인구 증가, 도시화의 확대, 염분화 및 기후변화들이 수자원 문제들의 주요 요인이며, 식습관 변화와 산업용수 수요 증가 등으로 악화되고 있는 상황</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>유럽의 물 부족 상황은 지역에 따라 다르게 나타나 스페인을 비롯한 남부유럽 에서 특히 심각함. 전반적으로 북유럽에 비해 남유럽의 물 스트레스가 심하며, 특히 스페인과 포르투갈, 프랑스 동부지역의 물 스트레스가 심각함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>유럽 연합에서도 물 부족 문제를 인식하고 2000년 EU Water Framework Directive을 개발, 이후 2020년 재이용에 관한 법률을 제정</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,229 @@
// apps/dm/app/(landing)/reuse/business/globalCase/overview/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="해외 재이용 사례 개요"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "사례개요", href: "/reuse/business/globalCase/overview" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/overview_01.png", alt: "해외 재이용 사례 1" },
]}
/>
<section className="mt-6">
<div className="rounded-md bg-[#e9f0f1] p-8">
<h3 className="text-xl font-bold text-gray-800 leading-relaxed">
2024년 하수도 통계 기준 15.6%이며,<br />
제2차 물 재이용 기본 계획을 고려한 2030년 목표는 <span className="font-extrabold">17%</span>
</h3>
{/* 진행 바 영역 */}
<div className="mt-6 space-y-4">
{/* 2024 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">’24년도</span>
<div className="relative h-7 rounded-r-lg">
<div className="h-7 w-[112px] rounded-r-lg bg-gray-400" />
</div>
<span className="text-sm text-gray-700">15.6%</span>
</div>
{/* 2030 */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">’30년도</span>
<div className="relative">
<div className="h-10 w-[327px] rounded-r-lg bg-gradient-to-r from-sky-500 to-green-500" />
</div>
<span className="text-xl font-bold text-gray-900">17%</span>
</div>
</div>
</div>
{/* 설명 문구 */}
<ul className="mt-6 space-y-2 text-base text-gray-600">
<li>
• 우리나라의 하수 처리 재이용률은 <span className="text-sky-600 ">15% 수준</span>
</li>
<li>
• 대만은 가뭄과 공업용수 증가에 대응하여
<span className="text-sky-600 ">
{" "}현재 4.3% 수준이며, 16.5%까지 확대 계획 수립
</span>
</li>
</ul>
{/* 테이블 */}
<div className="mt-6">
<table className="w-full text-center text-sm">
<thead>
<tr>
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">해외정보</th>
<th className="bg-teal-500 px-4 py-3 text-white rounded-t-lg">현재</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">미래(목표, 예측치)</th>
</tr>
</thead>
<tbody className="bg-white border border-t-0">
<tr className="border-t">
<td className="px-4 py-3 font-medium">한국</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">12.1%</td>
<td className="px-4 py-3">15.6%</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">17%</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 font-medium">대만</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">2.4%</td>
<td className="px-4 py-3">4.3%</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">16.5%</td>
</tr>
</tbody>
</table>
</div>
</section>
<Hr className="my-11" />
{/* 주요 국가의 하수처리수 재이용률 (%) */}
<section className="pb-[200px]">
<SectionTitle>주요 국가의 하수처리수 재이용률 (%)</SectionTitle>
{/* 설명 문구 */}
<ul className="mt-6 space-y-2 text-base text-gray-600">
<li>
• 전 세계적으로 물 재이용은 호주, 유럽, 아프리카, 미국 등 가뭄이 잦거나 심각한 가뭄이 발생한 국가에서 많이 이루어짐
</li>
<li>
• 이스라엘, 쿠웨이트 등 중동 및 북아프리카 지역은 가용 수자원이 제한되어 있어 재이용량 비중도 매우 높은 편
</li>
</ul>
{/* 테이블 (2단 병렬: 좌/우 데이터) */}
<div className="mt-6">
<div className="overflow-x-auto">
<table className="min-w-[900px] w-full text-center text-sm">
<thead>
<tr>
{/* 좌측 3컬럼 */}
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
{/* 우측 3컬럼 */}
<th className="bg-[#C2CAD2] px-4 py-3 text-black rounded-t-lg">국가명</th>
<th className="bg-sky-600 px-4 py-3 text-white rounded-t-lg">지역</th>
<th className="bg-green-500 px-4 py-3 text-white rounded-t-lg">재이용률</th>
</tr>
</thead>
<tbody className="bg-white border border-t-0">
{[
{
L: { c: "이스라엘", r: "중동&북아프리카", v: "92.3" },
R: { c: "튀르키예", r: "동유럽&중앙아시아", v: "26.6" },
},
{
L: { c: "쿠웨이트", r: "중동&북아프리카", v: "89.3" },
R: { c: "독일", r: "서유럽", v: "25.0" },
},
{
L: { c: "호주", r: "동아시아&태평양", v: "49.0" },
R: { c: "스페인", r: "서유럽", v: "24.4" },
},
{
L: { c: "싱가포르", r: "동아시아&태평양", v: "44.4" },
R: { c: "한국", r: "동아시아&태평양", v: "12.1" },
},
{
L: { c: "그리스", r: "서유럽", v: "39.1" },
R: { c: "미국", r: "북아메리카", v: "13.1" },
},
{
L: { c: "사우디아라비아", r: "중동&북아프리카", v: "32.8" },
R: { c: "프랑스", r: "서유럽", v: "9.5" },
},
{
L: { c: "이탈리아", r: "서유럽", v: "31.7" },
R: { c: "대만", r: "동아시아&태평양", v: "2.4" },
},
{
L: { c: "룩셈부르크", r: "서유럽", v: "29.1" },
R: { c: "일본", r: "동아시아&태평양", v: "0.8" },
},
{
L: { c: "포르투갈", r: "서유럽", v: "29.0" },
R: { c: "", r: "", v: "" }, // 마지막 우측은 빈칸(이미지처럼)
},
].map((row, idx) => (
<tr key={idx} className="border-t">
{/* 좌측 */}
<td className="px-4 py-3 font-medium text-gray-700">{row.L.c}</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.L.r}</td>
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.L.v}</td>
{/* 우측 */}
<td className="px-4 py-3 font-medium text-gray-700">{row.R.c}</td>
<td className="px-4 py-3 bg-sky-50 text-gray-700">{row.R.r}</td>
<td className="px-4 py-3 bg-green-50 text-gray-700">{row.R.v}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,172 @@
// apps/dm/app/(landing)/reuse/business/globalCase/singapore/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[싱가포르] 수요가 높은 산업의 물 재이용 의무화"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "싱가포르", href: "/reuse/business/globalCase/singapore" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/singapore_01.png", alt: "싱가포르 사례 1" },
]}
/>
{/* 싱가포르 NEWater 비중 확대 */}
<section className="mt-8">
<SectionTitle>싱가포르 NEWater 비중 확대</SectionTitle>
<div className="px-4 mt-6">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설
</li>
<li>
• NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용
</li>
<li>
• 2060년까지 NEWater 비중 55%까지 높이는 것이 싱가포르의 목표
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 싱가포르 재이용수 의무화 및 지원정책 */}
<section className="mt-6">
<SectionTitle>싱가포르 재이용수 의무화 및 지원정책</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 공업용수 수요가 높은 산업에 대해 물 재이용을 의무화<br />
<ul className="list-inside text-[#767676] text-sm ml-4 mt-2">
<li>– 반도체업계 전체 비 재이용수(non-domestic용수) 17% 차지 → 공업용수 수요 원인</li>
<li>– 2024년 1월 1일부터 연간 최소 60,000㎥를 소비하는 사업자는 재이용수 사용 의무화</li>
<li>– 반도체 공장의 경우 최소 50% 이상의 재이용수를 요구함(현재는 43% 정도)</li>
</ul>
</li>
<li>
• 재정 지원을 통해 재이용 활성화를 지원<br />
<ul className="list-inside text-[#767676] text-sm ml-4 mt-2">
<li>– 재이용 설비를 기준으로 2025년까지 싱가포르 수자원공사의 2가지 금융상품 제공<br/>
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>1. Water Efficiency Fund</li>
<li>2. Industrial Water Solutions Demonstration Fund</li>
</ul>
</li>
<li className="mt-2">– 2025년 기준 지원 한도 상향 → 물 재이용 활성 도모<br/>
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>투입비용 50% → 75% 지원 / WSDP: $400만 → $500만</li>
<li>물 효율이 높은 장비를 도입하는 데에도 최대 $300만 지원 가능</li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>싱가포르는 천연 수자원이 많지 않아, 말레이시아 조호르와 1962년 맺은 협약으로 물을 수입해왔으며, 재이용수를 늘리기 위한 노력을 계속하고 있음<br/>
<ul className="list-inside text-[#767676] text-sm ml-4 mt-2">
<li>- 싱가포르는 국토 내에 호수와 하천이적고 집수공간도 적어 빗물로 확보할 수 있는 수자원량은 전체 물 수요의 20%에 불과함</li>
</ul>
</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>싱가포르는 도심에 저수지를 건설하 고 건물에 빗물이용시설을 건설하여 빗물을 재이용하고 있음</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>싱가포르는 2002년부터 하수재이용수인 NEWater를 개발, 비음 용수와 간접음용수로 활용하고 있음<br/>
<ul className="list-inside text-[#767676] text-sm/6 ml-4 mt-2">
<li>- 재이용수를 NEWater라는 이름으로 명명하고 NEWater에 대한 신뢰 확보를 위해 NEWater 방문자 센터도 건설함</li>
<li>- 비음용수로 많이 사용하며 건기에는 NEWater를 저수지에 추가하여 원수와 섞음. 원수는 상수도에서 처리 한 후 가정에 수돗물로 공급함</li>
<li>- NEWater는 전용 파이프를 통해 주로 웨이퍼 제조 공장, 산업단지, 상업용 건물에서 산업 및 냉각수로 사용. 제일 많이 사용하는 곳은 웨이퍼 제조 공장</li>
</ul>
</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>2024년 4월 기준 1㎥ 당 공업용수 가격은 $1.66이며, Non-domestic 기준 NEWater 가격은 $2.41</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>공업용수 가격 $1.66은 수도요금(water tariff)과 수자원세(waterborne tax)로 구분됨. $1.66에는 $1만큼의 수자원세가 포함됨</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>2023년 싱가포르는 산업용수 수요가 높은 산업에 대해 물 재 이용을 의무화했으며, 정부는 기금을 통해 물 재이용을 지원함</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,216 @@
// apps/dm/app/(landing)/reuse/business/globalCase/taiwan/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="[대만] 국가 주도의 공업용수 공급"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "해외 재이용 사례" },
{ label: "대만", href: "/reuse/business/globalCase/taiwan" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/globalCase/taiwan_01.png", alt: "대만 사례 1" },
]}
/>
{/* 재생 수자원 개발법 제정(2015년제정, 2022개정) */}
<section className="mt-8">
<SectionTitle>재생 수자원 개발법 제정(2015년제정, 2022개정)</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-2 text-base text-gray-600">
<li>
• 목적: 수자원 지속 가능성 향상, 재생 수자원 활용을 통해 물부족 문제 해결
</li>
<li>
• 내용: 재생 수자원 정의·분류, 이용 의무, 이용 제한, 수질 기준 등
</li>
</ul>
<div className="rounded-sm bg-[#e9f0f1] p-8 mt-5">
<ul className="space-y-1 text-2xl text-[#1A1A1A]">
<li>
– 제4조. 물 소비 계획에 따른 재이용수 이용 관련 내용 (일정 비율 재이용수 사용 규정)
</li>
<li>
– 제5조. 재이용수의 운영자, 사용자 관련 내용 (지자체에서 하수 무상공급 제공 가능)
</li>
<li>
– 제7조. 재이용수 이용 범위, 이용량 재이용수 수질 기준 내용 (식품, 제약산업 사용 불가)
</li>
<li>
– 제8조. 재이용수 사업 주체, 책임 등에 관한 내용 (광역인 경우, 정부가 지자체 지정)
</li>
</ul>
</div>
</div>
</section>
<Hr className="my-11" />
{/* 대만정부 하수재이용 사업 시행 */}
<section className="mt-6">
<SectionTitle>대만정부 하수재이용 사업 시행</SectionTitle>
<div className="px-4">
<ul className="mt-6 space-y-4 text-base text-gray-600">
<li>
• 대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑)<br />
<ul className="list-inside text-[#767676] ml-4 mt-2">
<li>– 1,618억의 사업비 중 92% 국비, 지방비 8% 투입</li>
<li>– 재이용수는 24km 관로로 타이난 TSMC로 공급</li>
</ul>
</li>
<li>
• 안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능
</li>
<li>
• 공급단가는 수질과 운영조건에 따라 다르게 책정
</li>
</ul>
{/* 테이블 (2단 병렬: 좌/우 데이터) */}
<div className="mt-3">
<div className="overflow-x-auto">
<p className="text-center text-2xl py-2">〈대만 재이용 시설별 재이용수 공급단지〉</p>
<table className="min-w-[900px] w-full text-center text-sm">
<thead className="border">
<tr>
<th className="bg-[#C2CAD2] px-4 py-3 text-black">사업</th>
<th className="bg-sky-600 px-4 py-3 text-white">가격(NTD/㎥)</th>
<th className="bg-green-500 px-4 py-3 text-white">가격(원/㎥)</th>
</tr>
</thead>
<tbody className="bg-white text-[#4F4F4F] border border-t-0">
<tr className="border-t">
<td className="px-4 py-3 bg-white">Fongshan(가오슝)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">23.39</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,038원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Linhai(가오슝)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">19.28</td>
<td className="px-4 py-3 bg-[#E7F4E2]">858원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Anping(타이난)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">38.28</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,699원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Yongkang(타이난)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">23.67</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,050원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Rende(타이난)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">28.26</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,254원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Shuinan(타이중)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">15.00</td>
<td className="px-4 py-3 bg-[#E7F4E2]">668원</td>
</tr>
<tr className="border-t">
<td className="px-4 py-3 bg-white">Quaitou(가오슝)</td>
<td className="px-4 py-3 bg-[#EAF5F5]">35.44</td>
<td className="px-4 py-3 bg-[#E7F4E2]">1,573원</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<ul className="mt-6 space-y-4 pl-4 text-base text-gray-600">
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 풍부한 강수량에도, 지형적 특성으로 인해 수자원 확보에 어려움을 겪고 있으며, 대만 남부지역의 경우 우기와 건기 사이의 강수량 차이가 큼</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 최근 심각한 가뭄이 여러 해에 걸쳐서 발생하고 있으며, 강수량 감소와 함께 대만 정부 주도하에 반도체 산업을 국가 핵심 산업으로 선정하고 반도체 산업 육성 계획을 세움. 그 결과, 2023년 파운드리 시장의 68.3%를 차지하는 반도체 강국으로 성장했으며 이로 인한 공업용수 수요가 크게 증가</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>반도체 산업은 물 소비가 많아, 안정적인 용수공급이 매우 중요한데 현재 19개의 반도체 공장이 물이 부족한 대만 남부지역에 위치함. 2021년 가뭄 발생 당시, 산업단지 기업들도 용수 공급에 어려움을 겪음. 이는 대만 남부 지역의 물 부족, 물 분배 등에 대한 문제를 야기함</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만 정부는 산업단지 기업들에게 2023년부터 물에 대한 추가요금 부과를 시작했으며, 농업용수에 대해서는 강력한 억제정책을 시행함. 안정적인 용수 공급을 위해서 정부와 기업에서 자체적으로 재이용수 시설에 대한 투자가 이루어지고 있음.</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 2004년 기후변화로 인한 물 부족 상황을 해결하기 위해 수자원 보호 전략을 승인하고, 2015년 대만 환경부는 재생 수자원 개발법을 발표, 2022년에는 한 차례 개정</li>
</ul>
</li>
<li>
<ul className="list-disc list-outside pl-6 space-y-1 leading-relaxed">
<li>대만은 물 소비량이 많은 산업(high water consuming industries)과 하수처리장(municipal wastewater treatment plants), 두 곳을 재이용수의 주요 공급원으로 계획</li>
</ul>
</li>
</ul>
</section>
</div>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import Lnb from "@/components/Lnb";
export default function BusinessLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb />
<main className="flex-1 px-[100px] pt-[80px]">{children}</main>
</div>
);
}
@@ -0,0 +1,93 @@
// apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="경기도 수원시 빗물이용과 중수도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "빗물 재이용 사례" },
{ label: "수원시", href: "/reuse/business/rain/suwon" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/rain/rain_01.png", alt: "수원시 하수 재이용 1" },
{ src: "/images/landing/business/rain/rain_02.png", alt: "수원시 하수 재이용 2" },
{ src: "/images/landing/business/rain/rain_03.png", alt: "수원시 하수 재이용 3" },
{ src: "/images/landing/business/rain/rain_04.png", alt: "수원시 하수 재이용 4" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
경기도 수원시 빗물이용과 중수도
</p>
</section>
<Hr className="my-11" />
{/* 사업 방식 */}
<section className="pb-[200px]">
<SectionTitle>사업 방식</SectionTitle>
<p className="mt-8 text-base text-gray-600">
(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로 비산먼지 저감<br/>
※ 시설 가동중 도로 노면 온도의 7.2 ~ 26.3℃ 감소 효과 확인(‘21.7월, 수원시청).<br/>
다만, 가뭄기나 강우량이 적을 경우 빗물을 원활히 공급하기 다소 곤란<br/><br/>
- (중수도) 수원시청, 장안구청, 월드컵경기장에 빗물 연계 중수도를 화장실·조경용수 등으로 사용하여 수도요금 절감(연 7백~30백만원)
</p>
</section>
</div>
);
}
@@ -0,0 +1,111 @@
// apps/dm/app/(landing)/reuse/business/sewage/bolyeong/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="충남보령 하수 재이용"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "충청남도 보령시", href: "/reuse/business/sewage/bolyeong" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/bolyeong_01.png", alt: "보령시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
충남 보령 하수재이용<br/>
: 보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진
</p>
</section>
<Hr className="my-11" />
{/* 사업 방식 */}
<section className="mt-6">
<SectionTitle>사업 방식</SectionTitle>
<p className="mt-8 text-base text-gray-600">
2017년 한국환경공단과 사업대행 위 · 수탁 협약 체결
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
처리공정 : 전처리 UF + 후처리 RO<br/>
시설용량 : 1만m³/일<br/>
관로 : 8.7km<br/>
사업비 : 315억원<br/>
위치: 충청남도 보령시 대청방조제로 322 일원
</p>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<p className="mt-8 text-base text-gray-600">
국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU(환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕으로 추진 되어 경제성보다 공익성을 바탕으로<br/>
국가전력 기반시설의 안정적 운영 도모
</p>
</section>
</div>
);
}
@@ -0,0 +1,94 @@
// apps/dm/app/(landing)/reuse/business/sewage/cheongju/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="청주시 하수 재이용 시설"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "청주시", href: "/reuse/business/sewage/cheongju" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/cheongju_01.png", alt: "청주시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
청주시 하수 재이용 시설
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="pb-[200px]">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
청주시 하수처리수 재이용 민간투자사업(BTO)<br/>
공급량 : 35,000㎥/일<br/>
공급관로 : 8.3㎞<br/>
사업비 : 622억원<br/>
수요처 : SK하이닉스 등 일반산업단지<br/>
지역 (위치) : 충청북도 청주시 흥덕구 가락리 135-9번지 일원<br/>
처리용량 : 35.000(m³/일)<br/>
적용기술 : 분리막 (UF : 전처리), (RO : 주처리)
</p>
</section>
</div>
);
}
@@ -0,0 +1,148 @@
// apps/dm/app/(landing)/reuse/business/sewage/guri/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
// type ImgCardProps = {
// src: string;
// alt: string;
// };
// function ImgCard({ src, alt }: ImgCardProps) {
// return (
// <div className="w-full overflow-x-auto">
// <img
// src={src}
// alt={alt}
// className="block mx-auto max-w-none"
// />
// </div>
// );
// }
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="구리시 도로자동청소시스템"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "구리시", href: "/reuse/business/sewage/guri" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/guri_01.png", alt: "구리시 도로자동청소시스템 1" },
{ src: "/images/landing/business/sewage/guri_02.png", alt: "구리시 도로자동청소시스템 2" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
구리시 도로자동청소시스템 설치사업
</p>
</section>
<Hr className="my-11" />
{/* 사업목적 */}
<section className="mt-6">
<SectionTitle>사업목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
고농도 미세먼지 발생 및 폭염의 급증에 따라 공공하수처리시설 발생 재이용수 일부를 도로자동청소시스템에 활용하여 미세먼지 저감 및 폭염 취약성 완화
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : 61억원<br/>
위 치 : 구리시 경춘로 및 갈매중앙로 일대 등<br/>
관 로 : 5.8km<br/>
공급계획<br/>
</p>
</section>
<Hr className="my-11" />
{/* 공급계획 */}
<section className="pb-[200px]">
<SectionTitle>공급계획</SectionTitle>
{/* 데이터 테이블 */}
<div className="overflow-x-auto mt-8">
<table className="w-full border border-[#E3E7EA] text-center">
<thead>
<tr>
<th className="bg-[#01ACE5] text-white font-semibold py-4 border border-gray-300">
시설명
</th>
<th className="bg-[#33B5C8] text-white font-semibold py-4 border border-gray-300">
재이용수 용도
</th>
<th className="bg-[#55BCA2] text-white font-semibold py-4 border border-gray-300">
공급량(㎥/일)
</th>
<th className="bg-[#6DBB4F] text-white font-semibold py-4 border border-gray-300">
원수
</th>
</tr>
</thead>
<tbody>
<tr>
<td
className="bg-white py-6 border border-gray-300 text-gray-700"
>
도로자동청소시스템
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
세척살수용수
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
178
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
구리 공공하수처리장 재이용수
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -0,0 +1,173 @@
// apps/dm/app/(landing)/reuse/business/sewage/hwanggu/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="수원시 황구지천 하천유지용수"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "수원시 황구지천", href: "/reuse/business/sewage/hwanggu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/hwanggu_01.png", alt: "수원시 황구지천 하수 재이용 1" },
{ src: "/images/landing/business/sewage/hwanggu_02.png", alt: "수원시 황구지천 하수 재이용 2" },
{ src: "/images/landing/business/sewage/hwanggu_03.png", alt: "수원시 황구지천 하수 재이용 3" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
수원시 황수원시 황구지천 하수처리수 재이용사업
</p>
</section>
<Hr className="my-11" />
{/* 사업목적 */}
<section className="mt-6">
<SectionTitle>사업목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
황구지천 공공하수처리시설 하수처리수 재이용사업으로 향후 호매실천 및 금곡천 건천화 방지로 주민들에게 친수 공간 제공 및 하천 생태계 보전
</p>
</section>
<Hr className="my-11" />
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : 110억원<br/>
위 치 : 수원시 권선구 호매실천, 금곡천<br/>
관 로 : 3.9km<br/>
공 급 량 : 25,000㎥/일(하수처리시설 용량: 45,000㎥/일)<br/>
</p>
</section>
<Hr className="my-11" />
{/* 공급계획 */}
<section className="pb-[200px]">
<SectionTitle>공급계획</SectionTitle>
{/* 데이터 테이블 */}
<div className="overflow-x-auto mt-8">
<table className="w-full border border-[#E3E7EA] text-center">
<thead>
<tr>
{/* 구분 */}
<th className="w-1/4 bg-[#E3E7EA] text-black font-semibold py-4 border border-gray-300">
구분
</th>
{/* 공급량 */}
<th className="w-2/4 bg-[#1FA3C6] text-white font-semibold py-4 border border-gray-300">
공급량
</th>
{/* 비고 */}
<th className="w-2/4 bg-[#72B84F] text-white font-semibold py-4 border border-gray-300">
비고
</th>
</tr>
</thead>
<tbody>
<tr>
{/* 좌측 병합 */}
<td
rowSpan={3}
className="bg-white font-medium text-gray-700 border border-gray-300"
>
하천 등 유지용수
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
12,500m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
호매실천 공급
</td>
</tr>
<tr>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
6,250m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
금곡천1 공급
</td>
</tr>
<tr>
<td className="bg-white py-6 border border-gray-300 text-gray-700 text-lg font-medium">
6,250m³/일
</td>
<td className="bg-white py-6 border border-gray-300 text-gray-700">
금곡천2 공급
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
);
}
@@ -0,0 +1,112 @@
// apps/dm/app/(landing)/reuse/business/sewage/paju/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="경기도 파주시 하수 재이용"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "파주시", href: "/reuse/business/sewage/paju" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/paju_01.png", alt: "파주시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
경기도 파주시 하수재이용<br/>
: 운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에 공업용수 공급<br/>
*LG디스플레이(P10공장), 희성전자, 엔비텍 등
</p>
</section>
<Hr className="my-11" />
{/* 사업 배경 및 목적 */}
<section className="mt-6">
<SectionTitle>사업 배경 및 목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-6">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
처리공정 : 전처리 UF + 후처리 RO<br/>
공급량 : 4만m³/일<br/>
관로 : 13.6km<br/>
사업비 : 651억원<br/>
위치 : 경기도 파주시 금촌동 714번지 일원<br/>
시설용량 : 41,20m³/일
</p>
</section>
<Hr className="my-11" />
{/* 특이사항 */}
<section className="pb-[200px]">
<SectionTitle>특이사항</SectionTitle>
<p className="mt-8 text-base text-gray-600">
하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례
</p>
</section>
</div>
);
}
@@ -0,0 +1,101 @@
// apps/dm/app/(landing)/reuse/business/sewage/yeosu/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
import CaseImageSlider from "@/components/CaseImageSlider";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-2xl font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="여수시 하수처리수 재이용 민간투자사업(BTO)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 사례" },
{ label: "하수 재이용 사례" },
{ label: "여수시", href: "/reuse/business/sewage/yeosu" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 슬라이드 이미지 영역 */}
<CaseImageSlider
className="mt-8"
maxWidthClassName="max-w-[760px]"
slides={[
{ src: "/images/landing/business/sewage/yeosu_01.png", alt: "여수시 하수 재이용 1" },
]}
/>
{/* 사업명 */}
<section className="mt-6">
<SectionTitle>사업명</SectionTitle>
<p className="mt-8 text-base text-gray-600">
여수시 하수처리수 재이용 민간 투자사업(BTO)
</p>
</section>
<Hr className="my-11" />
{/* 사업 목적 */}
<section className="mt-6">
<SectionTitle>사업 목적</SectionTitle>
<p className="mt-8 text-base text-gray-600">
수요자가 원하는 양질의 공업용수를 저렴하게 곰급하여 한정된 수자원의 효율적 이용 및 지역적 물 부족 해소, 하수재이용을 통한 오염총량 저감 및 방류수역 수질개선
</p>
</section>
{/* 사업 주요 내용 */}
<section className="pb-[200px]">
<SectionTitle>사업 주요 내용</SectionTitle>
<p className="mt-8 text-base text-gray-600">
총사업비 : GS칼텍스, YNCC(주) 등 750억원<br/>
시행사 : 여수그린허브(주)<br/>
운영사 : 테크로스<br/>
위치: 여수시 신월로 2844일원 (여수 하수처리시설 부지 내)<br/>
처리공법 : 전처리시설(MF) + 주처리시설(RO)<br/>
시설용량 및 공급량 : 5만m³/일<br/>
관로 : 18.6km
</p>
</section>
</div>
);
}
@@ -0,0 +1,86 @@
// apps/dm/app/(landing)/reuse/intro/greywater/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="중수도"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "중수도" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 중수 재이용 소개 */}
<section className="mt-6">
<SectionTitle>중수 재이용 소개</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_intro.png" alt="중수 재이용 소개" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 중수도 설치 의무 대상 */}
<section>
<SectionTitle>중수도 설치 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_mandatory.png" alt="중수도 설치 의무 대상 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 중수도 시설기준 및 관리기준 */}
<section className="pb-[200px]">
<SectionTitle>중수도 시설기준 및 관리기준</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/greywater_standard.png" alt="중수도 시설기준 및 관리기준 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,122 @@
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function WaterReuseLawPage() {
return (
<div className="bg-white">
<SubPageHeader
title="물 재이용 법"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "물 재이용 법" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 법제도/계획 수립 타임라인 */}
<section className="mt-6">
<SectionTitle>법제도 · 계획 및 기본계획 수립</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/law_timeline.png" alt="법제도 타임라인" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 제2차 계획 대비 실적 현황 (2컬럼) */}
<section>
<SectionTitle>제 2차 계획 대비 실적 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/performance_left.png" alt="실적 현황 요약" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 국내 관련 현황 */}
<section>
<SectionTitle>국내 관련 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/domestic_textbox.png" alt="국내 관련 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 정책 변천 추이 */}
<section>
<SectionTitle>물 재이용 정책 변천 추이</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/policy_timeline.png" alt="정책 변천 타임라인" />
</div>
</section>
<Hr className="my-11" />
{/* 5) 타 법령과의 관계도 */}
<section>
<SectionTitle>타법령과의 관계도</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rel_note.png" alt="관계도 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 6) 국내 연계 현황 */}
<section className="pb-[200px]">
<SectionTitle>국내 연계 현황</SectionTitle>
<div className="mt-8">
<div className="mx-[20px] mb-8">
<p className="text-[24px] font-semibold mb-2">주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className="text-[#4F4F4F]">국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요<br />
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
</p>
</div>
<ImgCard src="/images/landing/intro/linkage.png" alt="국내 연계 현황" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import Lnb from "@/components/Lnb";
export default function IntroLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb />
<main className="flex-1 px-[100px] pt-[80px]">{children}</main>
</div>
);
}
@@ -0,0 +1,76 @@
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function RainwaterReusePage() {
return (
<div className="bg-white">
<SubPageHeader
title="빗물 이용시설"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "빗물 이용시설" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 빗물 재이용 소개 */}
<section className="mt-6">
<SectionTitle>빗물 재이용 소개</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rainreuse_intro.png" alt="빗물 재이용 소개" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 빗물이용시설 설치 관리 법적 의무 대상 */}
<section className="pb-[200px]">
<SectionTitle>빗물이용시설 설치 관리 법적 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/rainreuse_mandatory.png" alt="법적 의무 대상 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,76 @@
// apps/dm/app/(landing)/reuse/intro/treated/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function TreatedWaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="하수처리수"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "하수처리수" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 하수처리수 */}
<section className="mt-6">
<SectionTitle>하수처리수</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/treatedwater_intro.png" alt="하수처리수 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 하수처리수 재이용시설 의무 대상 */}
<section className="pb-[200px]">
<SectionTitle>하수처리수 재이용시설 의무 대상</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/intro/treatedwater_mandatory.png" alt="하수처리수 재이용시설 의무 대상 설명" />
</div>
</section>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
// app/(with-layout)/layout.tsx
import "../reuse.module.css";
import { StoreProvider } from "../store";
import TopNav from "@/components/TopNav";
export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) {
return (
<StoreProvider>
<TopNav />
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
</StoreProvider>
);
}
@@ -0,0 +1,96 @@
// apps/dm/app/(landing)/reuse/cases/greywater/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="중수도(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "중수도(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외) */}
<section className="mt-6">
<SectionTitle>전체 중수도 설치 및 이용 현황 (공장 및 발전시설 제외)</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_overall.png" alt="전체 중수도 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 중수공장 및 발전시설 내 중수도 설치 및 이용 현황 */}
<section>
<SectionTitle>공장 및 발전시설 내 중수도 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_factory.png" alt="공장 및 발전시설 내 중수도 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 중수도 건축물별 이용량 현황 */}
<section className="pb-16">
<SectionTitle>중수도 건축물별 이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_building.png" alt="중수도 건축물별 이용량 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 중수도 용도별 이용량 현황 */}
<section className="pb-[200px]">
<SectionTitle>중수도 용도별 이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/greywater_usage.png" alt="중수도 용도별 이용량 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import Lnb from "@/components/Lnb";
export default function CasesLayout({ children }: { children: ReactNode }) {
return (
<div className="flex gap-0">
<Lnb />
<main className="flex-1 px-[100px] pt-[80px]">{children}</main>
</div>
);
}
@@ -0,0 +1,86 @@
// apps/dm/app/(landing)/reuse/cases/rain/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="빗물이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "빗물이용시설(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section className="mt-6">
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_building.png" alt="빗물이용시설의 건축물별 빗물이용량 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
<section>
<SectionTitle>빗물이용시설의 용도별 분포 (2023년 기준)</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_usage.png" alt="빗물이용시설의 용도별 분포 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section className="pb-[200px]">
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/rainwater_usage_amount.png" alt="빗물이용시설의 용도별 빗물이용량 현황 설명" />
</div>
</section>
</div>
);
}
@@ -0,0 +1,96 @@
// apps/dm/app/(landing)/reuse/cases/treated/page.tsx
"use client";
import SubPageHeader from "@/components/SubPageHeader";
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
</div>
);
}
type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
type ImgCardProps = {
src: string;
alt: string;
};
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<img
src={src}
alt={alt}
className="block mx-auto max-w-none"
/>
</div>
);
}
export default function GreywaterPage() {
return (
<div className="bg-white">
<SubPageHeader
title="하수처리수 재이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "하수처리수 재이용시설(현황)" },
]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
/>
<Hr className="my-7" />
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section className="mt-6">
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_year.png" alt="연도별 하수처리수 재이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_usage.png" alt="용도별 하수처리수 재이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_facility.png" alt="하수처리시설 설치 및 이용 현황 설명" />
</div>
</section>
<Hr className="my-11" />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section className="pb-[200px]">
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<ImgCard src="/images/landing/cases/treated_water_region.png" alt="시도별 하수처리수 재이용 현황 설명" />
</div>
</section>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import type { RegionData, } from "../store/wrStatus/type";
export const regionData:RegionData[] = [
{ name: '서울', x: 170, y: 140, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '인천', x: 120, y: 150, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경기', x: 210, y: 210, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '강원', x: 330, y: 120, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '충북', x: 260, y: 280, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '충남', x: 100, y: 320, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '세종', x: 180, y: 305, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '대전', x: 195, y: 360, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '전북', x: 170, y: 450, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '전남', x: 160, y: 600, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '광주', x: 120, y: 530, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경북', x: 400, y: 320, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경남', x: 310, y: 530, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '대구', x: 350, y: 450, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '울산', x: 450, y: 500, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '부산', x: 420, y: 550, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '제주', x: 60, y: 750, values: [0.3, 0.3, 0.2, 0.2] },
]
export const regionIdMap: Record<string, string> = {
서울: '서울특별시',
부산: '부산광역시',
대구: '대구광역시',
인천: '인천광역시',
광주: '광주광역시',
대전: '대전광역시',
울산: '울산광역시',
세종: '세종특별자치시',
경기: '경기도',
강원: '강원특별자치도',
충북: '충청북도',
충남: '충청남도',
전북: '전라북도',
전남: '전라남도',
경북: '경상북도',
경남: '경상남도',
제주: '제주특별자치도'
};
export const makeRegion = (region: string) => {
if (region === '서울특별시') return '서울'
if (region === '부산광역시') return '부산'
if (region === '대구광역시') return '대구'
if (region === '인천광역시') return '인천'
if (region === '광주광역시') return '광주'
if (region === '대전광역시') return '대전'
if (region === '울산광역시') return '울산'
if (region === '세종특별자치시') return '세종'
if (region === '경기도') return '경기'
if (region === '강원특별자치도') return '강원'
if (region === '충청북도') return '충북'
if (region === '충청남도') return '충남'
if (region === '전북특별자치도') return '전북'
if (region === '전라남도') return '전남'
if (region === '경상북도') return '경북'
if (region === '경상남도') return '경남'
if (region === '제주특별자치도') return '제주'
return region;
}
@@ -0,0 +1,26 @@
import React from 'react'
type CommonProps = {
value: string | number | '';
format: string;
}
export default function TransValueFormat({value='', format=''}: CommonProps) {
return (
format === 'yyyyMMdd' ?
<>{value.toString().substring(0,4)+'-'+value.toString().substring(4,6)+'-'+value.toString().substring(6,8)}</>
: format === 'number' ?
<>{String(value ?? '').includes('.')
? String(value).split('.').map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
).join('.')
: String(value ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")}</>
: format === 'float1' ?
<>{String(value ?? '').includes('.')
? String(value).split('.').map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
).join('.')
: String(value ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")}</>
: <>{value}</>
)
}
+83
View File
@@ -0,0 +1,83 @@
/* @import "../../../packages/common/components/src/base.css"; */
/* @import "../../../packages/common/components/src/base.css"; */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@font-face {
font-family: "NotoSansKR";
src: url("/fonts/NotoSansKR-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
body {
font-family: "NotoSansKR", sans-serif;
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import "./globals.css";
import { setCookie } from "../src/shared/lib";
setCookie("site-id-key", process.env.NEXT_PUBLIC_SITE_ID || "DM");
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<head>
<title>물재이용 대민</title>
</head>
<body>
<div className="min-w-[1800px] overflow-x-auto">
{children}
</div>
</body>
</html>
);
}
+99
View File
@@ -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<string | null>(null);
const clearTimerRef = useRef<number | null>(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 (
<aside className={styles.sideIndicator} aria-label="섹션 이동">
<div className={styles.sideIndicatorLine} aria-hidden="true" />
<ul className={styles.sideIndicatorList}>
{items.map((it) => {
const isActive = it.id === currentId;
const isMain = it.id === "main";
const displayLabel = it.sideLabel ?? it.label;
return (
<li key={it.id} className={styles.sideIndicatorItem}>
<button
type="button"
onClick={() => scrollTo(it.id)}
className={[
styles.indicatorBtn,
isActive ? styles.indicatorActive : styles.indicatorDot,
isActive
? isMain
? styles.indicatorMainActive
: styles.indicatorSectionActive
: "",
].join(" ")}
aria-current={isActive ? "location" : undefined}
aria-label={displayLabel}
>
{/* ✅ 텍스트 중복 렌더링 제거:
- active일 때만 시각적 텍스트 표시
- 비활성은 SR-only로만 제공 (CSS에 따라 필요 없으면 제거 가능) */}
{isActive ? (
<span className={styles.indicatorText}>{displayLabel}</span>
) : (
<span className="sr-only">{displayLabel}</span>
)}
</button>
</li>
);
})}
</ul>
</aside>
);
}
@@ -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<Map<string, number>>(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;
}
+65
View File
@@ -0,0 +1,65 @@
// apps/dm/app/(landing)/reuse/_sections/Cases.tsx
"use client";
export default function Cases() {
return (
<section id="cases" className="bg-[#EAF6F6]">
<div className="mx-auto max-w-[1520px] px-6 h-[870px] flex items-center">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full">
{/* 좌측 : 카피 영역 */}
<div className="flex flex-col justify-center">
<div className="pl-2 lg:pl-10">
<p className="text-[32px] font-extrabold text-slate-900 tracking-[-0.02em]">
빗물+중수도+하수도
</p>
<h2 className="mt-6 leading-[1.1] tracking-[-0.03em]">
<span className="block text-[64px] font-extrabold text-slate-900">
데이터로 보는
</span>
<span className="mt-3 block text-[64px] font-extrabold text-[#004A94]">
물 재이용 현황
</span>
</h2>
<div className="mt-14">
<a
href="/reuse/cases/rain"
className="
inline-flex items-center justify-center gap-3
w-[260px] h-[52px]
bg-[#01ACE5] text-white
rounded-sm
hover:bg-[#0A78C0]
transition-colors
"
>
물 재이용 현황 보러가기
<span className="text-lg">→</span>
</a>
</div>
</div>
</div>
{/* 우측 : 이미지 카드 */}
<div className="flex justify-center lg:justify-end">
<div
className="
relative
w-full max-w-[720px]
"
>
<img
src="/images/landing/section_03.png"
alt="물 재이용 현황 데이터 테이블"
className="w-full h-auto object-cover"
/>
</div>
</div>
</div>
</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
// app/main/_sections/Hero.tsx
import styles from "../../reuse.module.css";
export default function Hero() {
return (
<section id="main" className={styles.hero}>
<div className={styles.heroBg} aria-hidden />
<div className={styles.heroOverlay} aria-hidden />
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="flex flex-col items-center justify-items-center gap-[40px] mx-auto max-w-[1520px] px-6 text-center text-white">
<h1 className="text-5xl font-bold tracking-tight">물 재이용</h1>
<p className="text-xl font-medium leading-6 text-white/85 max-w-[720px] mx-auto">
빗물, 오수, 하수처리수, 폐수처리수, 발전소 온배수를 재이용처리하여<br />
생활, 공업, 농업, 조경, 하천유지 등의 용도로 재이용합니다.
</p>
<p className="text-lg leading-6 text-white/85 max-w-[720px] mx-auto">
전국 하수 재이용률 15.6%(2024년 통계 기준),<br />
물재이용 활성화를 위한 재이용 수요신청, 매칭을 추진하고 있습니다.
</p>
</div>
</div>
<div className="absolute inset-x-0 bottom-[120px] flex flex-col items-center justify-items-center gap-2 mt-10">
<img src="/images/landing/scroll_down.svg" alt="" className="w-[26px] h-[40px]" />
<span className="text-white/80 text-base tracking-[.25em]">scroll down</span>
</div>
</section>
);
}
@@ -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 (
<section id="intro" className="bg-white">
<div className="mx-auto max-w-[1520px] px-6 py-[90px] h-[870px]">
<div className="text-center">
<div className="inline-block">
<h2 className="relative z-10 px-2 text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 소개</h2>
<div className="relative top-[-7px] -z-1 h-4 bg-gradient-to-r from-sky-300 to-green-300"></div>
</div>
<p className="mt-5 text-xl text-gray-500">
지속 가능한 미래를 위한 약속, 깨끗한 물의 새로운 순환을 시작합니다.
</p>
</div>
<section className="mx-auto max-w-8xl px-4 py-12">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{INTRO_CARDS.map((card, idx) => (
<Link
key={idx}
href={card.href}
aria-label={`${card.title} 상세로 이동`}
className="group relative h-[280px] md:h-[320px] lg:h-[500px] overflow-hidden rounded-2xl cursor-pointer focus:outline-none focus:ring-2 focus:ring-sky-400/60"
>
{/* 배경 이미지 (이미 그라디언트 포함) */}
<img
src={card.image}
alt={card.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 hover:scale-105"
/>
{/* 텍스트 영역 */}
<div className="absolute bottom-0 z-10 p-6 text-white">
<h3 className="text-lg font-semibold tracking-tight">
{card.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-white/90 line-clamp-3">
{card.desc}
</p>
</div>
</Link>
))}
</div>
</section>
</div>
</section>
);
}
@@ -0,0 +1,22 @@
// apps/dm/app/(landing)/reuse/_sections/ProcessFlow.tsx
import CaseTabsCarousel from "@/components/CaseTabsCarousel";
export default function ProcessFlow() {
return (
<section id="processflow" className="bg-white">
<div className="mx-auto max-w-[1520px] px-6 py-[50px] h-[870px]">
<div className="text-center">
<div className="inline-block">
<h2 className="relative z-10 px-2 text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 사례</h2>
<div className="relative top-[-7px] -z-1 h-4 bg-gradient-to-r from-sky-300 to-green-300"></div>
</div>
</div>
{/* 탭메뉴 영역 */}
<CaseTabsCarousel />
</div>
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
// app/main/layout.tsx
import "../reuse.module.css";
import TopNav from "@/components/TopNav";
import { StoreProvider } from "../store";
export default function ReuseLandingLayout({ children }: { children: React.ReactNode }) {
return (
<StoreProvider>
<TopNav />
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
</StoreProvider>
);
}
+58
View File
@@ -0,0 +1,58 @@
// apps/dm/app/(landing)/reuse/page.tsx
"use client";
import TopNav from "@/components/TopNav";
import SideIndicator from "./SideIndicator";
import { useScrollSpy } from "./_hooks/useScrollSpy";
import Hero from "./_sections/Hero";
import IntroCards from "./_sections/IntroCards";
import Cases from "./_sections/Cases";
import ProcessFlow from "./_sections/ProcessFlow";
const LANDING_SECTIONS = [
{ id: "main", label: "Main" },
{
id: "intro",
label: "물 재이용 소개", // ✅ 상단 메뉴용
sideLabel: "소개", // ✅ 사이드 인디게이터용
},
{
id: "cases",
label: "물 재이용 현황", // ✅ 상단 메뉴용
sideLabel: "현황", // ✅ 사이드 인디게이터용
},
{
id: "processflow",
label: "물 재이용 사례", // ✅ 상단 메뉴용
sideLabel: "재이용사례", // ✅ 사이드 인디게이터용
},
];
export default function ReuseLandingPage() {
const activeId = useScrollSpy(LANDING_SECTIONS.map((s) => s.id));
return (
<main className="bg-white">
{/* 헤더 */}
<TopNav />
{/* 사이드 인디게이터 */}
<SideIndicator items={LANDING_SECTIONS} activeId={activeId} />
{/* 컨텐츠 영역 */}
<div className="[&>section]:scroll-mt-[83px]">
{/* 물 재이용 */}
<Hero />
{/* 재이용 소개 */}
<IntroCards />
{/* 재이용 사례 */}
<Cases />
{/* 물 재이용 사업 */}
<ProcessFlow />
</div>
</main>
);
}
+238
View File
@@ -0,0 +1,238 @@
/* apps/dm/app/(landing)/reuse/reuse.module.css */
.hero {
position: relative;
min-height: 100vh;
overflow: hidden;
font-family: "NotoSansKR", sans-serif;
}
.heroBg {
position: absolute;
inset: 0;
background: url("/images/landing/hero_bg.jpg") center/cover no-repeat;
transform: scale(1.02);
}
.heroOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.50);
}
/* ===== Side Indicator (첨부 이미지 스타일) ===== */
.sideIndicator {
position: fixed;
right: 28px;
top: 50%;
transform: translateY(-50%);
z-index: 40;
/* 원형 배지 폭만큼만 */
width: 60px;
/* (디자인처럼) 스크롤 영역 위에 뜨되 클릭 가능 */
pointer-events: none;
}
/* 세로 라인: 연한 하늘색 + 점선(도트) 느낌 */
.sideIndicatorLine {
position: absolute;
left: 50%;
top: -150px;
bottom: -150px;
width: 2px;
transform: translateX(-50%);
background: rgba(46, 177, 235, 0.25);
background-size: 2px 16px;
background-repeat: repeat-y;
z-index: -1;
}
/* 리스트는 가운데 정렬로 수직 배치 */
.sideIndicatorList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 56px; /* 4개 간격(시안에 맞게 조절) */
pointer-events: auto;
}
.sideIndicatorItem {
display: flex;
justify-content: center;
}
/* 공통 버튼 */
.indicatorBtn {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
position: relative;
z-index: 2;
display: grid;
place-items: center;
}
/* ✅ 기본: 작은 점 */
.indicatorDot {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(30, 167, 225, 0.55);
border: 1px solid rgba(30, 167, 225, 0.7);
}
/* ✅ 활성: 커지는 큰 원(텍스트 포함) */
.indicatorActive {
width: 83px;
height: 83px;
border-radius: 999px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
/* Main 활성: 흰 배경 + 검정 텍스트 */
.indicatorMainActive {
background: #ffffff;
border: 2px solid rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.75);
}
/* 섹션 활성: 파란 배경 + 흰 텍스트 */
.indicatorSectionActive {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #ffffff;
/* box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18); */
}
/* 활성 텍스트 */
.indicatorText {
font-size: 12px;
font-weight: 700;
line-height: 1.1;
text-align: center;
padding: 0 8px;
word-break: keep-all;
white-space: normal;
}
/* 공통 원형 버튼 */
.sideIndicatorDot {
width: 58px;
height: 58px;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
user-select: none;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease,
border-color 0.15s ease;
/* 라인 위로 올라오게 */
position: relative;
z-index: 1;
pointer-events: auto;
}
.sideIndicatorDotText {
font-size: 12px;
font-weight: 600;
line-height: 1.1;
text-align: center;
padding: 0 8px;
/* 긴 텍스트도 배지 안에서 줄바꿈 가능 */
word-break: keep-all;
white-space: normal;
}
/* Main: 흰색 원형 + 회색 텍스트 */
.sideIndicatorDotMain {
background: #ffffff;
border: 2px solid rgba(0, 0, 0, 0.15);
color: rgba(0, 0, 0, 0.65);
}
/* 섹션: 파란 원형 + 흰색 텍스트 */
.sideIndicatorDotSection {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #ffffff;
}
/* 활성 섹션: 더 진한 파랑 + 살짝 글로우 */
.sideIndicatorDotActive {
transform: scale(1.02);
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
}
/* hover 효과(이미지 느낌으로 아주 약하게만) */
.sideIndicatorDot:hover {
transform: translateY(-1px);
}
/* 반응형: 모바일에서는 숨김(원하시면 제거) */
@media (max-width: 768px) {
.sideIndicator {
display: none;
}
}
/* 중앙 배지 */
.sideIndicatorBadge {
width: 58px;
height: 58px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 600;
text-align: center;
z-index: 1;
}
/* Main 배지 */
.badgeMain {
background: #fff;
border: 2px solid rgba(0,0,0,.15);
color: rgba(0,0,0,.65);
}
/* Section 배지 */
.badgeSection {
background: #1ea7e1;
border: 2px solid rgba(30, 167, 225, 0.85);
color: #fff;
box-shadow: 0 0 0 8px rgba(30, 167, 225, 0.18);
}
/* 작은 점 */
.sideIndicatorDotSmall {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(30,167,225,.6);
}
/* 점 그룹 */
.sideIndicatorDotsTop,
.sideIndicatorDotsBottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.sideIndicatorDotsTop { margin-bottom: 18px; }
.sideIndicatorDotsBottom { margin-top: 22px; }
+502
View File
@@ -0,0 +1,502 @@
/**====================================
* 보편적으로 쓰는 store 공간
====================================**/
import { fetchType, getFetch } from '@/components/ui/api';
/**
* STORE - TYPE
*/
// selectBox
export type SelectDataType = {
text: string;
val: string;
}
export interface MenuItem {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: MenuItem[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
// grid header
export type TableUpperProps = {
id: string;
title: string;
upName?: string;
upSequnce?: number;
align? : string;
row? : number;
rowSpan?: number;
colSpan?: number;
}
// 시도 데이터
export type sidoDataType = {
adstDstrCd: number,
sido: string,
}
// 시군구 데이터
export type sigunDataType = {
adstDstrCd: number,
gugun: string
}
// 권역 데이터
export type regionDataType = {
rvbsnCd: string,
rvbsn: string,
}
// 단위유역 데이터
export type unitWaterShedDataType = {
rvbsnCd: string,
lgbsn: string,
}
// 중권역 데이터
export type middleRegionDataType = {
rvbsnCd: string,
mdmbsn: string,
}
// 환경청 데이터
export type envPartDataType = {
lclgvrnmntCd: string,
lclgvrnmntNm: string,
adstDliStrCd: string,
adstDstrCd: string,
}
export type envPartSelectDataType = SelectDataType & {
adstDliStrCd: string,
}
/**
* STORE - DATA
*/
// 시도 데이터
let selectSidoData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 시군구 데이터
let selectSigunData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 권역 데이터
const selectRegionData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 단위유역 데이터
const selectUnitWaterShedData: SelectDataType[] = [
];
// 환경청 데이터
let selectEnvPartData: envPartSelectDataType[] = [
{ text: "전체", val: '0', adstDliStrCd: '' }
];
// 환경청 내 시도 데이터
const selectEnvPartSidoData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 환경청 내 시군구 데이터
const selectEnvPartSigunData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 구분 데이터
// const selectPartData:SelectDataType[] = [
// { text: "행정별", val: '0' },
// { text: "유역별", val: '01' },
// { text: "환경청별", val: '02' },
// ];
// 상향 하향 데이터
const selectUpdownData:SelectDataType[] = [
{ text: "전체", val: '0' },
{ text: "500이하", val: '499' },
{ text: "500이상", val: '500' },
];
// 운영 상태 데이터
const selectOperationData:SelectDataType[] = [
{ text: "전체", val: '0' },
{ text: "가동중", val: '1' },
{ text: "재가동", val: '2' },
{ text: "가동중지", val: '3' },
{ text: "시설폐쇄", val: '4' },
];
// 시설 구분 데이터
const selectFacilityPartData:SelectDataType[] = [
{ text: "전체", val: '0' },
{ text: "공공하수처리시설", val: 'SW' },
{ text: "오수처리장", val: 'SS' },
{ text: "개인하수처리시설", val: 'PB' },
{ text: "분뇨처리장", val: 'NS' },
{ text: "폐수처리장", val: 'LW' },
{ text: "침출수처리장", val: 'LC' },
{ text: "기타기초시설", val: 'ET' },
];
// 년도 데이터
const getYearData = () => {
const year = new Date().getFullYear();
const year1 = year - 1;
const year2 = year - 2;
const year3 = year - 3;
const year4 = year - 4;
const year5 = year - 5;
return [
{ text: year1 + "년", val: year1.toString() },
{ text: year2 + "년", val: year2.toString() },
{ text: year3 + "년", val: year3.toString() },
{ text: year4 + "년", val: year4.toString() },
{ text: year5 + "년", val: year5.toString() },
]
}
const selectSearchYear:SelectDataType[] = getYearData();
// 유량 기간 구분
const selectFlowTerm : SelectDataType[] = [
{ text: "연간", val: '0' },
{ text: "하절기", val: '01' },
{ text: "동절기", val: '02' },
{ text: "청천시", val: '03' },
{ text: "강우시", val: '04' },
];
// 유량 기간 구분
const selectFlowPanel : SelectDataType[] = [
{ text: "전체", val: '0'},
{ text: "3mm미만", val: '1'},
{ text: "3~10mm미만", val: '2'},
{ text: "10~20mm미만", val: '3'},
{ text: "20~30mm미만", val: '4'},
{ text: "30~40mm미만", val: '5'},
{ text: "40~50mm미만", val: '6'},
{ text: "50~60mm미만", val: '7'},
{ text: "60mm이상", val: '8'},
];
// 중권역 데이터
const selectMiddleRegionData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
/**
* STORE - API
*/
// 시도 API 호출 함수 추가
const fetchSidoData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/city');
const data = res.data;
selectSidoData = [
{ text: "전체", val: '0'},
...data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
}))
];
return selectSidoData;
} catch (error) {
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
return selectSidoData;
}
};
// 시군구 API 호출 함수 추가
const fetchSigunData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/sgg');
const data = res.data;
selectSigunData = [
{ text: "전체", val: '0' },
...data
.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
}))
];
return selectSigunData;
} catch (error) {
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
return selectSigunData;
}
};
// 권역 API 호출 함수 추가
const fetchRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/sarea');
const data = res.data;
return [
{ text: "전체", val: '0'},
...data.map((item: regionDataType) => ({
text: item.rvbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
} catch (error) {
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
return selectRegionData;
}
};
// 중권역 API 호출 함수 추가
const fetchMiddleRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/midArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: middleRegionDataType) => ({
text: item.mdmbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
} catch (error) {
console.error('중권역 데이터를 가져오는데 실패했습니다:', error);
return selectMiddleRegionData;
}
};
// 단위유역 API 호출 함수 추가
const fetchUnitWaterShedData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/largeArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: unitWaterShedDataType) => ({
text: item.lgbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
} catch (error) {
console.error('단위유역 데이터를 가져오는데 실패했습니다:', error);
return selectUnitWaterShedData;
}
};
// 환경청 데이터
const fetchEnvPartData = async (): Promise<envPartSelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/govArea');
const data = res.data;
selectEnvPartData = [
{ text: "전체", val: '0', adstDliStrCd: '' },
...data
.map((item: envPartDataType) => ({
text: item.lclgvrnmntNm || '',
val: String(item.lclgvrnmntCd) || '',
adstDliStrCd: item.adstDliStrCd || ''
}))
];
return selectEnvPartData;
} catch (error) {
console.error('환경청 데이터를 가져오는데 실패했습니다:', error);
return selectEnvPartData;
}
};
function sortMenus(items: MenuItem[]): MenuItem[] {
return [...items]
.sort((a, b) => a.sortSeq - b.sortSeq)
.map((item) => ({
...item,
children: item.children ? sortMenus(item.children) : [],
}));
}
function isVisible(item: MenuItem): boolean {
return item.useYn !== "N" && item.otptYn !== "N";
}
function normalizeMenus(items: MenuItem[]): MenuItem[] {
return sortMenus(items)
.filter(isVisible)
.map((item) => ({
...item,
children: item.children ? normalizeMenus(item.children) : [],
}));
}
export const REUSE_SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "DM";
const anonymousMenuCache = new Map<string, MenuItem[]>();
const anonymousMenuInFlight = new Map<string, Promise<MenuItem[]>>();
export async function fetchAnonymousMenu(siteId = REUSE_SITE_ID): Promise<MenuItem[]> {
const cached = anonymousMenuCache.get(siteId);
if (cached) return cached;
const inFlight = anonymousMenuInFlight.get(siteId);
if (inFlight) return inFlight;
const request = (async () => {
const response = await fetch(`/cms-service/api/v1/menu/anonymous/user/${siteId}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Failed to load menu: ${response.status}`);
}
const data = (await response.json()) as MenuItem[];
const normalized = Array.isArray(data) ? normalizeMenus(data) : [];
anonymousMenuCache.set(siteId, normalized);
return normalized;
})();
anonymousMenuInFlight.set(siteId, request);
try {
return await request;
} finally {
anonymousMenuInFlight.delete(siteId);
}
}
function isPathMatched(pathname: string, urlPath: string) {
if (!urlPath) return false;
return pathname === urlPath || pathname.startsWith(`${urlPath}/`);
}
function hasMatchedPathInTree(menu: MenuItem, pathname: string): boolean {
if (isPathMatched(pathname, menu.urlPath)) return true;
return (menu.children ?? []).some((child) => hasMatchedPathInTree(child, pathname));
}
export function findActiveTopMenu(menus: MenuItem[], pathname: string): MenuItem | null {
const level1Menus = menus.filter((menu) => menu.levelNo === 1 || menu.parentId === null);
const matched = level1Menus.filter((menu) => hasMatchedPathInTree(menu, pathname));
return matched[0] ?? level1Menus[0] ?? null;
}
export function hasAnyChild(item: MenuItem): boolean {
return Array.isArray(item.children) && item.children.length > 0;
}
export function isMenuActive(pathname: string, menu: MenuItem): boolean {
return isPathMatched(pathname, menu.urlPath);
}
/**====================================
* Store - expaort
====================================*/
export type CommonAction = {
initializeCommonData: () => Promise<void>;
getMenuAuth: (menuCd:number) => Promise<void>;
fetchAnonymousMenu: (siteId?: string) => Promise<MenuItem[]>;
}
export type CommonType = {
Common: {
isInit: boolean;
selectSidoData: SelectDataType[];
selectSigunData: SelectDataType[];
selectUpdownData: SelectDataType[];
selectOperationData: SelectDataType[];
selectFacilityPartData: SelectDataType[];
selectSearchYear: SelectDataType[];
selectUnitWaterShedData: SelectDataType[];
selectRegionData: SelectDataType[];
selectMiddleRegionData: SelectDataType[];
selectEnvPartSidoData: SelectDataType[];
selectEnvPartSigunData: SelectDataType[];
selectFlowTerm: SelectDataType[];
selectFlowPanel: SelectDataType[];
selectEnvPartData: envPartSelectDataType[];
btnClass: string;
}
}
export const CommonState:CommonType = {
Common: {
isInit: false,
selectSidoData: selectSidoData,
selectSigunData: selectSigunData,
selectUpdownData: selectUpdownData,
selectOperationData: selectOperationData,
selectFacilityPartData: selectFacilityPartData,
selectSearchYear: selectSearchYear,
selectRegionData: selectRegionData,
selectUnitWaterShedData: selectUnitWaterShedData,
selectMiddleRegionData: selectMiddleRegionData,
selectEnvPartSidoData : selectEnvPartSidoData,
selectEnvPartSigunData : selectEnvPartSigunData,
selectFlowTerm: selectFlowTerm,
selectFlowPanel: selectFlowPanel,
selectEnvPartData : selectEnvPartData,
btnClass: "p-4 m-2 text-white rounded-sm text-lg font-medium"
}
}
export const CommonReducer:(set:any)=>CommonAction=(set: any) => {
return {
getMenuAuth : async (menuCd : number) =>{
const response = await getFetch(
"local",
`/cms-service/api/v1/menu-authrts/pageinfo/${menuCd}`
);
set((state: CommonType) => {
return {
...state,
auth: {
mngAuthrtYn: response[0].mngAuthrtYn,
inqAuthrtYn: response[0].mngAuthrtYn,
regAuthrtYn: response[0].mngAuthrtYn,
mdfcnAuthrtYn: response[0].mngAuthrtYn,
delAuthrtYn: response[0].mngAuthrtYn,
},
};
});
},
// 시도 변경 이벤트
initializeCommonData: async () => {
const promiseSet = await Promise.all([fetchSidoData(), fetchSigunData(), fetchRegionData(), fetchUnitWaterShedData(), fetchMiddleRegionData(), fetchEnvPartData()]);
const [sidoData, sggData, regionData, unitData, midData, envData] = promiseSet;
set((state: CommonType) => ({
Common: {
...state.Common,
selectSidoData: sidoData,
selectSigunData: sggData,
selectRegionData: regionData,
selectMiddleRegionData: midData,
selectUnitWaterShedData: unitData,
selectEnvPartData: envData
}
})
)},
fetchAnonymousMenu: async (siteId = REUSE_SITE_ID) => fetchAnonymousMenu(siteId)
}
}
export type CommonStore = CommonType & CommonAction;
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { type ReactNode, createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { type DmStore, StoreZus } from './store'
export type StoreApi = ReturnType<typeof StoreZus>
export const StoreContext = createContext<StoreApi | undefined>(
undefined,
)
export interface StoreProviderProps {
children: ReactNode
}
export const StoreProvider = ({
children,
}: StoreProviderProps) => {
const storeRef = useRef<StoreApi | null>(null)
if (!storeRef.current) {
// Create store instance if not initialized
storeRef.current = StoreZus() as StoreApi
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
export const useDmStore = <T,>(
selector: (store: DmStore) => T,
): T => {
const storeContext = useContext(StoreContext)
if (!storeContext) {
throw new Error(`useStore must be used within StoreProvider`)
}
return useStore(storeContext, selector)
}
+114
View File
@@ -0,0 +1,114 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config";
import { fetchType, getFetch } from "@/components/ui/api";
import { DEV_URL_FO } from "@/components/ui/config";
import { getCookie } from "@common/components/lib/cookie";
import type { FacilityInfo, SubsInfo } from "./type";
const tokenId = getCookie(AUTH_USER_ID);
const Authorization = getCookie(CLAIM_NAME);
const checkWrRegList = async () => {
const wrRegList = await getFetch(fetchType.WR, `/reuseFc/pubFc`);
return wrRegList;
};
const getRuseFclt = async (
page: string,
sggOrCity: string,
searchTxt?: string
) => {
const checkWrRegListRes = await checkWrRegList();
let url = `/prcsFclt/ruseInfo/${page ? page : 1}/5/A/${sggOrCity ? sggOrCity : "0"}/0/1/SW`;
const ruseList = checkWrRegListRes.data.filter((item: string) => item !== "");
if (searchTxt) {
url = url + `/${searchTxt}`;
}
const response = await fetch(DEV_URL_FO + url, {
method: "POST",
headers: {
"Content-Type": "application/json",
[AUTH_USER_ID]: tokenId ?? "",
[CLAIM_NAME]: Authorization ?? "",
},
body: JSON.stringify(ruseList),
});
const hasuList = await response.json().then((res: any) => {
const data = res.data
.map((item: any) => {
return {
...item,
fctCpc: Number(item.fctCpc) || 0,
searchNm: item.fctNm,
searchPk: item.bscFctNm,
};
})
.filter((hasu:any) => ruseList.find((ruse:any) => hasu.bscFctCd === ruse));
return { data: data, count: res.count };
});
return hasuList;
};
export type DmMapActions = {
findRuseFclt(sggOrCity: string, searchTxt: string): void;
setSubsInfo(subsInfo: SubsInfo[]): void;
};
export type DmMapType = {
map: {
ruseFcltList: FacilityInfo[];
subsInfo: SubsInfo[];
};
};
export const dmMapState: DmMapType = {
map: {
ruseFcltList: [],
// subsInfo: [
// {
// ruseUtztnFcltNm: "수원제1,2 재이용1", // 재이용시설명
// ruseUtztnFcltCd: "41110PB001R1",
// },
// {
// ruseUtztnFcltNm: "낙양 재이용1", // 재이용시설명
// ruseUtztnFcltCd: "41150SW001R1",
// },
// {
// ruseUtztnFcltNm: "서남 재이용1", // 재이용시설명
// ruseUtztnFcltCd: "11500PB001R1",
// },
// ],
subsInfo:[]
},
};
export type DmMapStore = DmMapType & DmMapActions;
export const DmMapReducer = (set: any): DmMapActions => {
return {
findRuseFclt: async (sggOrCity: string, searchTxt: string) => {
const ruseFcltList = await getRuseFclt("1", sggOrCity, searchTxt);
set((state: DmMapType) => ({
...state,
map: {
...state.map,
ruseFcltList: ruseFcltList.data,
},
}));
},
setSubsInfo: async (subsInfo: SubsInfo[]) => {
set((state: DmMapType) => ({
...state,
map: {
...state.map,
subsInfo: subsInfo,
},
}));
},
};
};
+39
View File
@@ -0,0 +1,39 @@
export type FacilityInfo= {
bscFctCd: string; // 기본시설 코드
bscFctNm: string; // 기본시설명
rvbsnCd: string; // 유역 코드
rvbsn: string; // 유역명
lgbsn: string; // 대권역명
mdmbsn: string; // 중권역명
rgnCd: string; // 관할청 코드
rgnNm: string; // 관할청 명칭
adstDstrCd: string; // 행정구역 코드
sido: string; // 시도
gugun: string; // 구군
addr: string; // 주소
fctCpc: string; // 시설용량
efflWqltStndrdCd: string; // 방류수 수질기준 코드
efflWqltStndrdNm: string; // 방류수 수질기준명
trtFrfNm: string; // 처리공법명
trtDstr: string; // 처리구역
rnDt: string; // 사용개시일 (예: YYYYMMDD)
cmplDt: string; // 준공일자 (예: YYYYMMDD)
trstOprEtpNm: string; // 위탁운영기관명
bscFctClsNm: string; // 기본시설 구분명
sitArea: string; // 부지면적
trtDstrArea: string; // 처리구역 면적
swrTrtPopl: string; // 하수처리 인구
mnstr: string; // 총괄 유역
efflWtrrgn: string; // 방류수역
rvNm: string; // 하천명
bscFctClsCd: string; // 기본시설 구분 코드
rvbsnNm: string; // 유역명
tel: string; // 전화번호
rswtrYn: string; // 재이용수 여부 ("Y" 또는 "N")
ruse: boolean; // 재이용 여부 (boolean)
}
export type SubsInfo={
ruseUtztnFcltNm: string; // 재이용시설명
ruseUtztnFcltCd: string; // 재이용시설 코드
}
+121
View File
@@ -0,0 +1,121 @@
# Zustand store
## Zustand 등록방법
**-- index.tsx --**
### context 등록
기본적으로 createContext를 통해 react에 context를 생성해서 react에서 사용할 수 있도록 해야함.
``` js
//StoreZus는 내부적으로 사용하기 위한 store파일에서 따로 생성함.
export type StoreApi = ReturnType<typeof StoreZus>
//react에서 사용하기 위한 context 생성.
export const StoreContext = createContext<StoreApi | undefined>(
undefined,
)
```
### component로 작성
등록된 context를 component화 시키기 위한 작업
```js
//외부에서 호출시 <StoreProvider> 형태로 호출.
//app이나 home에서 전체적인 사용이 되도록 작성해야함.
export const StoreProvider = ({
children,
}: StoreProviderProps) => {
const storeRef = useRef<StoreApi>(null)
if (!storeRef.current) {
//StoreZus는 store.tsx를 봐야함.
storeRef.current = StoreZus()
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
```
### store 등록
react에서 사용하는 store에 현재 context 등록
```js
export const useWrStore = <T,>(
selector: (store: WrStore) => T,
): T => {
const storeContext = useContext(StoreContext)
if (!storeContext) {
throw new Error(`useStore must be used within StoreProvider`)
}
return useStore(storeContext, selector)
}
```
## Zustand 사용방법
**-- store.tsx, 폴더별 index.tsx --**
### 기본 store 생성
index.tsx의 context에 사용되는 StoreZus를 추가하여 사용됨.
외부에서 생성된 state, actions를 사용 가능한 구조
```js
import { createStore } from 'zustand/vanilla';
import { tableActionsExport, tableStateExport, type tableState, type tableType } from './publicReuseFaciltyInsert/index';//외부 파일 위치.
export type WrStore = tableType;//typescript이므로 사용되는 스크립트를 type1 & type2 형식으로 넣어야함.
//index.tsx에서 사용하는 store 생성.
export const StoreZus = (initState: tableState = tableStateExport) => {
return createStore<WrStore>()((set) => {
return {
//아래에서 사용하는 state & actions은 외부 폴더에서 가져다가 사용.
...initState,//외부에 있는 기본 초기값 state
...tableActionsExport(set)//외부에 있는 actions
}
})
}
```
### 외부 스토어 작성 방법
외부 스토어는 export를 어떻게 할건지만 정하면됨.
사용하는 store.tsx에서 import를 받기만 하면 사용가능.
#### 타입을 외부로 빼는 방법
```js
export type tableState = {
isInit: boolean;
upHeadList: TableUpperProps[];
publicReuseFacilityUper: PublicReuseFacility[];
}
type tableActions = {
decrementList: () => void
}
export type tableType = tableState & tableActions;
```
#### 초기값 state를 외부로 빼는 방법
```js
export const tableStateExport:tableState= {
isInit:false,
upHeadList: upHeadList,
publicReuseFacilityUper: publicReuseFacilityUper,
}
```
#### actions를 외부로 빼는 방법
```js
//tableActionsExport를 import 받아서 실행하여 return 된 값을 사용
export const tableActionsExport:(set:any)=>tableActions=
//set을 통해 react store에 등록
(set: any) => {
return {
//decrementList Object 형태로 전달
decrementList:
//set 함수를 실행해서 react store에 등록
()=>set(
// store에 등록되는 함수 구조를 가지고 있음.
(state:tableState) => {
//actions 실질적인 로직 작성하는 곳.
return (
{
upHeadList: state.upHeadList,
publicReuseFacilityUper:
state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1)
})}
)
}
}
```
+799
View File
@@ -0,0 +1,799 @@
import { fetchType, getFetch, postFetch } from "@/components/ui/api";
import type { SelectDataType } from "@common/business_components/ui";
import type { TableUpperProps } from "../common";
import toast from "react-hot-toast";
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { z } from "zod";
/**====================================
* Store - 타입
=====================================*/
export const sdmInfoFormSchema = z.object({
sdmNm: z.string().min(1, "수요처를 선택해주세요"),
demandYmd: z.date().optional(),
demandQnty: z
.string()
.min(1, "요구수량을 입력해주세요")
.regex(/^[0-9]+$/, "요구수량은 숫자만 입력 가능합니다"),
usePurpose: z.string().optional(),
money: z.string().regex(/^[0-9]+$/, "희망단가는 숫자만 입력 가능합니다"),
/**요구수질 */
reqWaterQlty: z.string().optional(),
grssClfg: z.number().nullish(),
tn: z.number().nullish(),
combResdch: z.number().nullish(),
tph: z.number().nullish(),
trbt: z.number().nullish(),
ph: z.string().nullish(),
bod: z.number().nullish(),
/**컬럼확인필요 */
clIonQnt: z.number().nullish(),
smll: z.string(),
cdtnv: z.number().nullish(),
crmty: z.number().nullish(),
rmrk: z.string().optional(),
/* telno: z.string()
.min(8, "전화번호는 8자 이상 입력해주세요")
.max(12, "전화번호는 12자 이내로 입력해주세요")
.regex(/^[0-9-]+$/, "전화번호는 숫자와 하이픈(-)만 입력 가능합니다"), */
candidateTitle: z
.array(
z.object({
ruseUtztnFcltNm: z.string(),
ruseUtztnFcltCd: z.string(),
})
)
.optional(),
}).superRefine((data, ctx) => {
const disabledFields = disableRules[data.usePurpose ?? ""] || [];
// 모든 수질 관련 필드
// 총대장균군수는 null 값을 불검출로 표시하기때문에 해당 검사에서 제외함.
const allFields: (keyof typeof data)[] = [
"tn", "combResdch", "tph",
"trbt", "ph", "bod", "clIonQnt",
"smll", "cdtnv", "crmty",
];
const allFieldsName: Record<string, string> = {
tn: "총질소(T-N)(mg/L)",
combResdch: "결합잔류염소(mg/L)",
tph: "총인(T-P)(mg/L)",
trbt: "탁도(NTU)",
ph: "수소이온농도(pH)",
bod: "생물학적 산소요구량(BOD)(mg/L)",
clIonQnt: "염화물(mgCL/L)",
smll: "냄새",
cdtnv: "전기전도도(㎲/cm)",
crmty: "색도(도)",
};
allFields.forEach((field) => {
const isDisabled = disabledFields.includes(field);
if (!isDisabled) {
// disabled가 아닌 필드는 반드시 값이 있어야 함
const value = data[field];
if (typeof value === "string") {
if (value.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: `${allFieldsName[field]} 을 입력해주세요.`,
});
}
} else if (typeof value === "number") {
if (value <= 0 || Number.isNaN(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: `${allFieldsName[field]} 을 입력해주세요.`,
});
}
} else if (value == null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: `${allFieldsName[field]} 을 입력해주세요.`,
});
}
}
});
});
export type SdmInfoFormData = z.infer<typeof sdmInfoFormSchema>;
// 수요처 정보
export type DemandCompBizType = {
index: number | '';
dmdInfoId: string | '';
dmdInfoFk: string | '';
bzmnNo: string | '';
dmdCoNm: string | '';
daddr: string | '';
telno: string | '';
rgtr: string | '';
useUsgCd: string | '';
ctpvCd: string | '';
sggCd: string | '';
dmdAplyPswd: string | '';
wntUntprc: number | '';
dmndTmng: Date | '';
dmndQty: number | '';
grssClfg: number | null //총대장균군수
tn: number | null //총질소
combResdch: number | null //결합잔류염소
tph: number | null //총인
trbt: number | null //탁도
ph: string | ''; //수소이온농
blgcOxygDmndQnt: number | null //생물학적산소요구량
clIonQnt: number | null //염화물
smll: string | ''; //냄새
cdtnv: number | null; //전기전도도
crmty: number | null //색도
rmrk: string | '';
reqDt: string | '';
dmdInfoSn: number | '';
status: string | '';
splyCndtList: SupplyCandidate[] | [],
ruseUtztnFcltNm?: string | ''
}
// 공급 후보자 타입 정의
export type SupplyCandidate = {
ruseUtztnFcltNm: string;
ruseUtztnFcltCd: string;
};
export type DemandMatchings = {
splyFcltFk: string | '';
splyFcltFlg: string | '';
pbnstRuseFcltBizFk: number | '';
splyBgngDay: string | '';
splyEndDay: string | '';
splyCpct: number | '';
splyFmYn: string | '';
useUsgCd: string | '';
dmdInfoSn: number | '';
splyCndtList: string | '';
mtchnCmptnYmd: string | '';
rvwCmptnYmd: string | '';
regDt: string | '';
rgtr: string | '';
mdfcnDt: string | '';
mdfr: string | '';
}
// 유사사례
export type SimilarCaseType = {
ruseBizNm: string | ''; // 사업명
bizBgngDate: string | '';
bizEndDate: string | '';
splyFcltKnd: string | '';
splyFcltNm: string | '';
splyFcltCpct: string | '';
dmdFcltNm: string | '';
dmdFcltAddr: string | '';
ruseFcltCpct: string | '';
ruseFcltPplLen: string | '';
ruseFcltUsg: string | '';
ruseFcltSplyCpct: string | '';
ruseFcltPrcsMsrc: string | '';
grnteWqUsg: string | '';
grnteWqGrssClfg: string | '';
grnteWqCombResdch: string | '';
grnteWqTrbt: string | '';
grnteWqBod: string | '';
grnteWqSmll: string | '';
grnteWqCrmty: string | '';
grnteWqTn: string | '';
grnteWqTp: string | '';
grnteWqPh: string | '';
grnteWqClIonQnt: string | '';
grnteWqCndctStat: string | '';
grnteWqRmrk: string | '';
reqBizReuseFcltCst: string | '';
reqBizPplCst: string | '';
reqBizArchCst: string | '';
reqBizMchnCst: string | '';
reqBizMtrngCst: string | '';
reqBizGisCst: string | '';
reqBizCctvCst: string | '';
reqBizCmcnCst: string | '';
reqBizWstCst: string | '';
reqBizDesignCst: string | '';
reqBizSpvsCst: string | '';
reqBizSbsdCst: string | '';
reqBizEtc: string | '';
reqBizGrssCst: string | '';
prcmPlanNlep: string | '';
prcmPlanLcep: string | '';
prcmPlanBrdnCst: string | '';
prcmPlanEcqrRt: string | '';
prcmPlanGrssCst: string | '';
clclnNlep: string | '';
clclnLcep: string | '';
clclnBrdnCst: string | '';
clclnEcqrRt: string | '';
clclnGrssCst: string | '';
}
// 사업비 상세정보
export type MoneyDetailType = {
reqBizGrssCst: string | '';
reqBizReuseFcltCst: string | '';
reqBizPplCst: string | '';
reqBizArchCst: string | '';
reqBizMchnCst: string | '';
reqBizMtrngCst: string | '';
reqBizGisCst: string | '';
reqBizCctvCst: string | '';
reqBizCmcnCst: string | '';
reqBizWstCst: string | '';
reqBizDesignCst: string | '';
reqBizSpvsCst: string | '';
reqBizSbsdCst: string | '';
reqBizEtc: string | '';
/** 재원조달계획(백만원원) */
prcmPlanGrssCst: string | '';
prcmPlanNlep: string | '';
prcmPlanLcep: string | '';
prcmPlanBrdnCst: string | '';
prcmPlanEcqrRt: string | '';
/** 사업비 정산(백만원) */
clclnGrssCst: string | '';
clclnNlep: string | '';
clclnLcep: string | '';
clclnBrdnCst: string | '';
clclnEcqrRt: string | '';
}
// 규칙 타입 선언
export type DisableRulesType = Record<string, string[]>;
/**====================================
* Store - 데이터
====================================*/
// 수요처 정보
const demandCompList: DemandCompType[] = [
]
const disableRules: DisableRulesType = {
"청소": ["tn", 'tph', "clIonQnt", 'cdtnv'],
"화장실용수": ["tn", 'tph', "clIonQnt", 'cdtnv'],
"세척": ["combResdch", "crmty", 'tph', 'tn', "clIonQnt", 'cdtnv'],
"살수": ["combResdch", "crmty", 'tph', 'tn', "clIonQnt", 'cdtnv'],
"조경": ["combResdch", "crmty", 'tph', 'cdtnv'],
"농업_간접용수": ["combResdch", "crmty", 'tn', 'tph', 'clIonQnt'],
"농업_직접용수": ["combResdch", "crmty", 'tn', 'tph', 'clIonQnt'],
"하천": ["combResdch", "trbt", "crmty", 'clIonQnt', 'cdtnv'],
'공업': []
};
const defaultRules: Record<string, object> = {
"청소": {
grssClfg: null,
combResdch: 0.2,
trbt: 2,
bod: 5,
crmty: 20,
ph: "5.8~8.5",
smll: '불쾌하지않을것',
tn: null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"화장실용수": {
grssClfg: null,
combResdch: 0.2,
trbt: 2,
bod: 5,
crmty: 20,
smll: '불쾌하지않을것',
ph: "5.8~8.5",
tn: null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"세척": {
grssClfg: null,
trbt: 2,
bod: 5,
ph: "5.8~8.5",
combResdch: null,
smll: '불쾌하지않을것',
crmty: null,
tn: null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"살수": {
grssClfg: null,
trbt: 2,
bod: 5,
ph: "5.8~8.5",
smll: '불쾌하지않을것',
combResdch: null,
crmty: null,
tn: null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"조경": {
grssClfg: null,
trbt: 2,
bod: 5,
ph: "5.8~8.5",
smll: '불쾌하지않을것',
clIonQnt: 250,
combResdch: null,
crmty: null,
tn: null,
tph: null,
cdtnv: null,
},
"농업_간접용수": {
grssClfg: null,
trbt: 5,
bod: 8,
ph: "5.8~8.5",
cdtnv: 2000,
combResdch: null,
crmty: null,
tn: null,
tph: null,
clIonQnt: null,
},
"농업_직접용수": {
grssClfg: null,
trbt: 2,
bod: 8,
smll: '불쾌하지않을것',
ph: "5.8~8.5",
cdtnv: 700,
combResdch: null,
crmty: null,
tn: null,
tph: null,
clIonQnt: null,
},
"하천": {
grssClfg: 1000,
bod: 5,
tn: 10,
tph: 0.5,
smll: '불쾌하지않을것',
ph: "5.8~8.5",
combResdch: null,
trbt: null,
crmty: null,
clIonQnt: null,
cdtnv: null
},
'공업': {
grssClfg: null,
bod: null,
tn: null,
tph: null,
ph: "~",
combResdch: null,
smll: '불쾌하지않을것',
trbt: null,
crmty: null,
clIonQnt: null,
cdtnv: null
}
};
// 사용용도
const useTypesData: SelectDataType[] = [
{ text: '청소', val: "청소" },
{ text: "화장실용수", val: "화장실용수" },
{ text: "세척", val: "세척" },
{ text: "살수", val: "살수" },
{ text: "조경", val: "조경" },
{ text: "농업_간접용수", val: "농업_간접용수" },
{ text: "농업_직접용수", val: "농업_직접용수" },
{ text: "하천", val: "하천" },
{ text: "공업", val: "공업" },
];
// 유사사례 모달 헤더
export const similarCaseModalHeader: TableUpperProps[] = [
{ id: "ruseBizNm", title: "사업명", row: 0, rowSpan: 2 },
{ id: "bizBgngDate", title: "사업시작일", row: 0, rowSpan: 2 },
{ id: "bizEndDate", title: "사업종료일", row: 0, rowSpan: 2 },
{ id: "A", title: "공급처", row: 0, colSpan: 3 },
{ id: "splyFcltKnd", title: "시설종류", row: 1 },
{ id: "splyFcltNm", title: "공급처명", row: 1 },
{ id: "splyFcltCpct", title: "시설용량\n(㎥/일)", row: 1 },
{ id: "B", title: "수요처", row: 0, colSpan: 2 },
{ id: "dmdFcltNm", title: "수요처명", row: 1 },
{ id: "dmdFcltAddr", title: "주소", row: 1 },
{ id: "C", title: "재이용시설", row: 0, colSpan: 5 },
{ id: "ruseFcltCpct", title: "시설용량\n(㎥/일)", row: 1 },
{ id: "ruseFcltPplLen", title: "관로\n(km)", row: 1 },
{ id: "ruseFcltUsg", title: "용도", row: 1 },
{ id: "ruseFcltSplyCpct", title: "공급량\n(㎥/일)", row: 1 },
{ id: "ruseFcltPrcsMsrc", title: "처리공법", row: 1 },
{ id: "D", title: "보증수질", row: 0, colSpan: 13 },
{ id: "grnteWqUsg", title: "용도별", row: 1 },
{ id: "grnteWqGrssClfg", title: "총대장균군수\n(개/100ml)", row: 1 },
{ id: "grnteWqCombResdch", title: "결합잔류염소\n(mg/L)", row: 1 },
{ id: "grnteWqTrbt", title: "탁도\n(NTU)", row: 1 },
{ id: "grnteWqBod", title: "생물학적산소요구량\n(BOD)\n(mg/L)", row: 1 },
{ id: "grnteWqSmll", title: "냄새\n(개/100ml)", row: 1 },
{ id: "grnteWqCrmty", title: "색도\n(도)", row: 1 },
{ id: "grnteWqTn", title: "총질소\n(T-N)\n(mg/L)", row: 1 },
{ id: "grnteWqTp", title: "총인\n(T-P)\n(mg/L)", row: 1 },
{ id: "grnteWqPh", title: "수소이온농도\n(pH)", row: 1 },
{ id: "grnteWqClIonQnt", title: "염화물\n(mgCL/L)", row: 1 },
{ id: "grnteWqCndctStat", title: "전기전도도\n(μS/cm)", row: 1 },
{ id: "grnteWqRmrk", title: "비고", row: 1 },
{ id: "reqBizGrssCst", title: "소요사업비\n(백만원)", row: 0, rowSpan: 2 },
]
// 유사사례 데이터
const similarCaseList: SimilarCaseType[] = [
]
// 사업비 상세정보 모달 헤더
export const moneyDetailModalHeader: TableUpperProps[] = [
{ id: "A", title: "소요사업비(백만원)", row: 0, colSpan: 14 },
{ id: "reqBizGrssCst", title: "총", row: 1, rowSpan: 2 },
{ id: "B", title: "토목", row: 1, colSpan: 2 },
{ id: "reqBizReuseFcltCst", title: "재이용시설", row: 2 },
{ id: "reqBizPplCst", title: "관로", row: 2 },
{ id: "reqBizArchCst", title: "건축", row: 1, rowSpan: 2 },
{ id: "reqBizMchnCst", title: "기계", row: 1, rowSpan: 2 },
{ id: "reqBizMtrngCst", title: "전기,계측", row: 1, rowSpan: 2 },
{ id: "reqBizGisCst", title: "GIS/DB", row: 1, rowSpan: 2 },
{ id: "reqBizCctvCst", title: "CCTV", row: 1, rowSpan: 2 },
{ id: "reqBizCmcnCst", title: "시공 및 준공", row: 1, rowSpan: 2 },
{ id: "reqBizWstCst", title: "폐기물", row: 1, rowSpan: 2 },
{ id: "reqBizDesignCst", title: "설계비", row: 1, rowSpan: 2 },
{ id: "reqBizSpvsCst", title: "감리비", row: 1, rowSpan: 2 },
{ id: "reqBizSbsdCst", title: "시설 부대비", row: 1, rowSpan: 2 },
{ id: "reqBizEtc", title: "기타", row: 1, rowSpan: 2 },
{ id: "C", title: "재원조달계획(백만원)", row: 0, colSpan: 5 },
{ id: "prcmPlanGrssCst", title: "총", row: 1, rowSpan: 2 },
{ id: "prcmPlanNlep", title: "국비", row: 1, rowSpan: 2 },
{ id: "prcmPlanLcep", title: "지방비", row: 1, rowSpan: 2 },
{ id: "prcmPlanBrdnCst", title: "원인자부담", row: 1, rowSpan: 2 },
{ id: "prcmPlanEcqrRt", title: "국고비율", row: 1, rowSpan: 2 },
{ id: "D", title: "사업비 정산(백만원)", row: 0, colSpan: 5 },
{ id: "clclnGrssCst", title: "총", row: 1, rowSpan: 2 },
{ id: "clclnNlep", title: "국비", row: 1, rowSpan: 2 },
{ id: "clclnLcep", title: "지방비", row: 1, rowSpan: 2 },
{ id: "clclnBrdnCst", title: "원인자부담", row: 1, rowSpan: 2 },
{ id: "clclnEcqrRt", title: "국고비율", row: 1, rowSpan: 2 },
]
// 사업비 상세정보
const moneyDetailList: MoneyDetailType[] = [
]
// 수요정보 신청현황 헤더
export const upHeadSdmInfoStatus: TableUpperProps[] = [
{ id: "index", title: "No." },
{ id: "ctpvCd", title: "시도", row: 0, rowSpan: 2 },
{ id: "sggCd", title: "시군구", row: 0, rowSpan: 2 },
{ id: "dmdCoNm", title: "회사명", row: 0, rowSpan: 2 },
{ id: "daddr", title: "공장주소", row: 0, rowSpan: 2 },
{ id: "telno", title: "전화번호", row: 0, rowSpan: 2 },
{ id: "dmndTmng", title: "신청일자", row: 0, rowSpan: 2 },
{ id: "status", title: "진행상태", row: 0, rowSpan: 2 },
]
// 수요정보 신청현황 데이터
/**====================================
* Store - Api
=====================================*/
// 수요처 등록
const postSdmData = async (data: DemandCompType) => {
try {
await postFetch("/reuseFc/request", data);
sessionStorage.setItem("user", JSON.stringify(data));
toast.success("수요처 등록에 성공하였습니다.");
return true;
} catch (error) {
console.log(error);
toast.error("수요처 등록에 실패하였습니다.");
return false;
}
}
// 수요정보 등록
const postSdmInfoData = async (data: DemandCompBizType) => {
try {
await postFetch("/reuseFc/request/demandInfo", data);
toast.success('수요 정보 등록에 성공하였습니다.');
} catch (error) {
console.log(error);
toast.error('수요 정보 등록에 실패하였습니다.');
}
}
const postSdmLoginData = async (data: DemandCompType) => {
try {
const res = await postFetch("/reuseFc/request/login", data);
const jsonData = await res.json();
const status = jsonData.status;
const loginData = jsonData.data;
let loginSuccess = false;
if (status === 200 && loginData != null) {
alert("수요처 로그인에 성공하였습니다.");
delete loginData.dmdAplyPswd;
delete loginData.rgtr;
sessionStorage.setItem("user", JSON.stringify(loginData));
loginSuccess = true;
} else if (status === 401) {
alert("패스워드가 일치하지 않습니다.");
loginSuccess = false;
} else {
alert("등록된 수요처가 없습니다.");
loginSuccess = false;
}
return loginSuccess;
} catch (error) {
console.log(error);
alert("수요처 로그인에 실패하였습니다.");
}
}
const getDemandCompList = async (searchText: string, pageCnt: number) => {
try {
const url = '/reuseFc/request/' + (searchText ? searchText : '') + '/' + pageCnt;
const res = await getFetch(fetchType.WR, url);
return {
data: res?.data || [],
count: res?.count || 0
}
} catch (error) {
console.error(error + ' 수요처 정보를 불러오지 못했습니다.');
return { data: [], count: 0 };
}
}
const getSdmInfoStatusList = async (dmdInfoId: string) => {
try {
const url = '/reuseFc/request/' + dmdInfoId;
const res = await getFetch(fetchType.WR, url);
return res?.data || [];
} catch (error) {
console.error(error + ' 수요정보 신청현황을 불러오지 못했습니다.');
return [];
}
}
// 유사사례 조회
const fetchGetSimilarCaseList = async (part: string, value: number | string) => {
try {
const url = '/reuseFc/pubFcRuseCase/dm/' + part + '/' + (value ? value : 0);
const res = await getFetch(fetchType.WR, url);
return res?.data || [];
} catch (error) {
console.error(error + ' 유사사례 조회에 실패하였습니다.');
return [];
}
}
/**====================================
* Store - expaort
====================================*/
export type SdmActions = {
initializeSdmData: () => void;
insertSdmData: (data: DemandCompType) => Promise<void>;
insertSdmInfoData: (data: DemandCompBizType) => Promise<void>;
getDemandCompList: (searchText: string, pageCnt: number) => Promise<void>;
actionLogin: (data: DemandCompType) => Promise<void>;
getSdmInfoStatusList: (dmdInfoId: string) => Promise<void>;
getSimilarCaseList: (part: string, value: number | string) => Promise<void>;
actionLogout: () => Promise<void>;
}
export type SdmType = {
Sdm: {
isInit: boolean;
useTypesData: SelectDataType[];
demandCompList: DemandCompType[];
upHeadSimilarCaseList: TableUpperProps[];
similarCaseList: SimilarCaseType[];
upHeadMoneyDetailList: TableUpperProps[];
moneyDetailList: MoneyDetailType[];
// 수요정보 신청현황
sdmInfoStatusList: DemandCompBizType[];
upHeadSdmInfoStatus: TableUpperProps[];
defaultRules: Record<string, object>
disableRules: DisableRulesType;
// 로그인관련
successNextPage: boolean;
successLogin: boolean;
}
}
export const SdmState: SdmType = {
Sdm: {
isInit: false,
useTypesData: useTypesData,
demandCompList: demandCompList,
upHeadSimilarCaseList: similarCaseModalHeader,
similarCaseList: similarCaseList,
upHeadMoneyDetailList: moneyDetailModalHeader,
moneyDetailList: moneyDetailList,
// 수요정보 신청현황
sdmInfoStatusList: [],
defaultRules: defaultRules,
disableRules: disableRules,
upHeadSdmInfoStatus: upHeadSdmInfoStatus,
// 로그인관련
successNextPage: false,
successLogin: false,
}
}
export const SdmReducer = (set: any): SdmActions => {
return {
initializeSdmData: async () => {
},
insertSdmData: async (data: DemandCompType) => {
const preRes = await getSdmInfoStatusList(data.dmdInfoId);
if (preRes?.dmdInfoId) {
toast.error('이미 등록된 수요처입니다.');
return;
}
const res = await postSdmData(data);
if (res) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
successNextPage: true,
}
}))
}
},
insertSdmInfoData: async (data: DemandCompBizType) => {
postSdmInfoData(data);
},
// 수요처 정보
getDemandCompList: async (searchText: string, pageCnt: number) => {
const res = await getDemandCompList(searchText, pageCnt);
if (res.data.length === 0) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
demandCompList: [],
}
}))
return;
} else {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
demandCompList: res.data,
}
}))
}
},
// 수요정보 신청현황
getSdmInfoStatusList: async (dmdInfoId: string) => {
const res = await getSdmInfoStatusList(dmdInfoId);
if (res.length === 0) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
sdmInfoStatusList: [],
}
}))
return;
}
//const sdmInfoStatusList : DemandCompBizType[] = [];
const sdmInfoStatusList: DemandCompBizType[] = res.demandInfo.map((el: DemandCompBizType, index: number) => {
const matchingInfo = res.demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
const status = matchingInfo?.splyFcltFlg?.trim() === 'Sub' ? '검토' : matchingInfo?.splyFcltFlg?.trim() === 'matched' ? '매칭완료' : '신청';
const ruseUtztnFcltNm = matchingInfo?.ruseUtztnFcltNm ? matchingInfo.ruseUtztnFcltNm : '';
return ({
...el,
index: ++index,
bzmnNo: res.bzmnNo,
dmdCoNm: res.dmdCoNm,
dmdInfoId: res.dmdInfoId,
daddr: res.daddr,
telno: res.telno.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'),
rgtr: res.rgtr,
ctpvCd: res.ctpvCd,
sggCd: res.sggCd,
useUsgCd: el.useUsgCd,
dmndTmng: el.dmndTmng,
dmdInfoSn: el.dmdInfoSn,
status: status,
splyCndtList: el.splyCndtList,
ruseUtztnFcltNm: ruseUtztnFcltNm || ''
})
})
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
sdmInfoStatusList: sdmInfoStatusList,
}
}))
},
actionLogin: async (data: DemandCompType) => {
const loginSuccess = await postSdmLoginData(data);
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
successLogin: loginSuccess,
}
}))
},
// 유사사례 조회
getSimilarCaseList: async (part: string, value: number | string) => {
const res = await fetchGetSimilarCaseList(part, value);
const similarCaseList = res.map((el: SimilarCaseType) => {
const maskedBizNm = el.ruseBizNm
? el.ruseBizNm.replace(/^(\s*)^\S+/, "***") // 첫 단어(공백 전까지) 치환
: el.ruseBizNm;
return {
...el,
ruseBizNm: maskedBizNm,
};
});
console.log(similarCaseList);
if (similarCaseList && similarCaseList.length !== 0) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
similarCaseList: similarCaseList,
}
}))
return;
} else {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
similarCaseList: [],
}
}))
return;
}
},
actionLogout: async () => {
sessionStorage.removeItem("user");
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
successLogin: false,
}
}))
}
}
}
export type SdmStore = SdmType & SdmActions;
+48
View File
@@ -0,0 +1,48 @@
import { createStore } from "zustand/vanilla";
import {
CommonReducer,
CommonState,
type CommonStore,
type CommonType,
} from "./common";
import { SdmReducer, SdmState, type SdmStore, type SdmType } from "./sdmMng";
import {
dmMapState,
type DmMapType,
type DmMapStore,
DmMapReducer,
} from "./map";
import { tokenManager } from "@/src/entities/auth/lib/token-manager";
import {
DmStatusReducer,
dmStatusState,
type DmStatusStore,
type DmStatusType,
} from "./wrStatus";
export type DmStore = CommonStore & SdmStore & DmMapStore & DmStatusStore;
export type DmState = CommonType & SdmType & DmMapType & DmStatusType;
export const StoreZus = (
initState: DmState = {
...CommonState,
...SdmState,
...dmMapState,
...dmStatusState,
}
) => {
return createStore<DmStore>()((set) => {
if (typeof window !== "undefined") {
tokenManager.setToken("site-id-key", process.env.NEXT_PUBLIC_SITE_ID||"DM");
tokenManager.setToken("site-theme-key", "green");
}
return {
...initState,
...CommonReducer(set),
...SdmReducer(set),
...DmMapReducer(set),
...DmStatusReducer(set),
};
});
};
+129
View File
@@ -0,0 +1,129 @@
import { fetchType, getFetch } from "@/components/ui/api";
import type {
DmStatusDataType,
DmStatusChartType,
DmStatusBoardType,
} from "./type";
import type { SelectDataType } from "../common";
import { makeRegion } from "@/app/common/comFunction";
export type DmStatusActions = {
getDmStatusData(cityCd: SelectDataType[]): void; // 데이터의 연도 반환
};
export type DmStatusType = {
dmStatus: {
year : string;
statusData: DmStatusDataType[];
facilityTotals: string[];
chartData: DmStatusChartType[];
};
};
export const dmStatusState: DmStatusType = {
dmStatus: {
year : '',
statusData: [],
facilityTotals: [],
chartData: [],
},
};
export type DmStatusStore = DmStatusType & DmStatusActions;
export const DmStatusReducer = (set: any): DmStatusActions => {
return {
getDmStatusData: async (cityCd: SelectDataType[]) => {
const data = await getFetch(fetchType.FO, "/dashBoard/daemin");
const year = data.data[0]?.year; // API 응답에서 year 값을 할당합니다.
console.log('cityCd ', cityCd)
const dataSum = data.data.reduce(
(acc: DmStatusDataType, item: DmStatusDataType) => {
acc.sewage = (
parseInt(acc.sewage.replaceAll(",", "")) +
parseInt(item.sewage.replaceAll(",", ""))
).toLocaleString();
acc.rainwater = (
parseInt(acc.rainwater.replaceAll(",", "")) +
parseInt(item.rainwater.replaceAll(",", ""))
).toLocaleString();
acc.reclaimed = (
parseInt(acc.reclaimed.replaceAll(",", "")) +
parseInt(item.reclaimed.replaceAll(",", ""))
).toLocaleString();
acc.sewageCnt = (
parseInt(acc.sewageCnt.replaceAll(",", "")) +
parseInt(item.sewageCnt.replaceAll(",", ""))
).toLocaleString();
acc.rainwaterCnt = (
parseInt(acc.rainwaterCnt.replaceAll(",", "")) +
parseInt(item.rainwaterCnt.replaceAll(",", ""))
).toLocaleString();
acc.reclaimedCnt = (
parseInt(acc.reclaimedCnt.replaceAll(",", "")) +
parseInt(item.reclaimedCnt.replaceAll(",", ""))
).toLocaleString();
return acc;
},
{
region: "합계",
sewage: "0",
rainwater: "0",
reclaimed: "0",
sewageCnt: "0",
rainwaterCnt: "0",
reclaimedCnt: "0",
}
);
data.data.unshift(dataSum);
//console.log("dataSum ", dataSum);
const facilityTotals = [
"하수재이용시설" + "(총" + dataSum?.sewageCnt + "개소)",
"빗물재이용시설" + "(총" + dataSum?.rainwaterCnt + "개소)",
"중수도재이용시설" + "(총" + dataSum?.reclaimedCnt + "개소)",
];
const boardData: DmStatusBoardType[] = data.data.map(
(item: DmStatusDataType) => {
const regionText = item.sidoNm; //cityCd.find((city) => item.region === city.val);
const sewage = item.sewage;
const rainwater = item.rainwater;
const reclaimed = item.reclaimed;
const sewageCnt = item.sewageCnt;
const rainwaterCnt = item.rainwaterCnt;
const reclaimedCnt = item.reclaimedCnt;
return {
region: regionText ? regionText : item.region,
sewage: sewageCnt + "(" + sewage.toLocaleString() + ")",
rainwater: rainwaterCnt + "(" + rainwater.toLocaleString() + ")",
reclaimed: reclaimedCnt + "(" + reclaimed.toLocaleString() + ")",
};
}
).filter((item): item is DmStatusBoardType => item !== undefined);
//console.log("boardData ", boardData);
const chartData = data.data.map((item) => ({
label: makeRegion(
cityCd.find((city) => item.region === city.val)?.text ?? ""
),
하수: item.sewage,
빗물: item.rainwater,
중수도: item.reclaimed,
}));
set((state: DmStatusType) => ({
...state,
dmStatus: {
...state.dmStatus,
year : year,
statusData: boardData,
chartData: chartData,
facilityTotals: facilityTotals,
},
}));
},
};
};
+41
View File
@@ -0,0 +1,41 @@
export type DmStatusDataType = {
sidoNm: string; // 시도명
region: string; // 시도코드
sewage: string;
rainwater: string;
reclaimed: string;
sewageCnt:string;
rainwaterCnt:string;
reclaimedCnt:string;
};
export type DmStatusBoardType = {
sidoNm: string; // 시도명
region: string;
sewage: string;
rainwater: string;
reclaimed: string;
};
export type DmStatusFcltType = {
sewageFclt: string;
rainwaterFclt: string;
reclaimedFclt: string;
thermalFclt: string;
};
export type DmStatusChartType = {
label: string,
하수: string,
빗물: string,
중수도: string,
}
export type RegionData = {
name: string;
x: number;
y: number;
values: number[];
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+167
View File
@@ -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 (
<section className={className}>
<div className={`mx-auto ${maxWidthClassName}`}>
<div className="relative">
{/* 좌측 버튼 */}
<button
type="button"
onClick={() => go(-1)}
className="absolute left-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_left_arrow.png"
alt="이전"
className="w-[12px] h-[22px]"
/>
</button>
{/* 우측 버튼 */}
<button
type="button"
onClick={() => go(1)}
className="absolute right-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_right_arrow.png"
alt="다음"
className="w-[12px] h-[22px]"
/>
</button>
{/* 뷰포트 */}
<div
className={[
"overflow-hidden",
"select-none touch-pan-y",
"cursor-grab active:cursor-grabbing",
"w-[583px] h-[400px] mx-auto",
].join(" ")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className="flex transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${idx * 100}%)` }}
>
{safeSlides.map((s, i) => (
<div key={`${s.src}-${i}`} className="min-w-full">
<img
src={s.src}
alt={s.alt ?? `slide-${i + 1}`}
className="w-full h-full object-contain"
draggable={false}
/>
</div>
))}
</div>
</div>
</div>
{/* 도트 */}
{canSlide && (
<div className="mt-3 flex items-center justify-center gap-2">
{safeSlides.map((_, i) => {
const active = i === idx;
return (
<button
key={i}
type="button"
onClick={() => setIdx(i)}
aria-label={`${i + 1}번 슬라이드`}
className={[
"h-2 w-2 rounded-full",
active ? "bg-black/70" : "bg-black/20 hover:bg-black/35",
].join(" ")}
/>
);
})}
</div>
)}
</div>
</section>
);
}
+570
View File
@@ -0,0 +1,570 @@
"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<TabKey, CaseItem[]> = {
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: [
{
id: "global-1",
title: "해외 재이용 사례 개요",
desc: "2024년 하수도 통계 기준 15.6%이며, 제2차 물 재이용 기본 계획을 고려한 2030년 목표는 17%, 우리나라의 하수 처리 재이용률은 15% 수준...",
imageSrc: "/images/landing/overview_01.png",
href: "reuse/business/globalCase/overview",
},
{
id: "global-2",
title: "[대만] 국가 주도의 공업용수 공급",
desc: "대만은 물 재이용에 적극적이며 국가주도로 하수재이용 사업 시행(안핑), 안핑을 포함한 16건 공사 완료시 628,100 CMD 공급 가능...",
imageSrc: "/images/landing/taiwan_01.png",
href: "/reuse/business/globalCase/taiwan",
},
{
id: "global-3",
title: "[호주] 생활 속 물 재이용 / 광역 물 재이용수 공급",
desc: "SEQ Water Grid는 브리즈번 남동부지역의 식수 공급을 위해 설정한 광역 수로망, 이 광역수로망을 활용하여 물이 부족한 지역으로 물을 공급...",
imageSrc: "/images/landing/australia_01.png",
href: "/reuse/business/globalCase/australia",
},
{
id: "global-4",
title: "[싱가포르] 수요가 높은 산업의 물 재이용 의무화",
desc: "2003년 공식 NEWater 공장을 가동했으며 신뢰확보를 위해 NEWater Visitor Center를 함께 건설, NEWater 전체파이프 공급 → 반도체 공장에서 가장 많이 사용...",
imageSrc: "/images/landing/singapore_01.png",
href: "/reuse/business/globalCase/singapore",
},
{
id: "global-5",
title: "[EU] 법률을 제정, EU국가에서 물 재이용 유도",
desc: "유럽 지역의 물 부족 문제를 인식하고 ’20년 물 재이용 법률을 제정 (2020/741), 2023년부터 모든 EU국가에서 시행...",
imageSrc: "/images/landing/eu_01.png",
href: "/reuse/business/globalCase/eu",
},
],
};
/* ---------------- utils ---------------- */
function chunk<T>(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<HTMLDivElement | null>(null);
const clipRef = useRef<HTMLDivElement | null>(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<TabKey>("rain");
// 탭별 마지막 그룹 인덱스 기억
const tabIndexRef = useRef<Record<TabKey, number>>({
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<HTMLDivElement>) => {
// 자식 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 (
<section className="w-full">
{/* 탭 바 */}
<div className="mx-auto max-w-[1520px] pt-10">
<div className="">
<div className="grid grid-cols-4">
{TABS.map((t) => {
const isActive = t.key === active;
return (
<button
key={t.key}
type="button"
onClick={() => setActive(t.key)}
className={[
"relative h-[74px] w-full text-base font-semibold",
"transition-colors",
// 기본(비활성): 검정 배경 + 회색 텍스트
!isActive ? "text-[#767676] hover:text-black border-b-2 border-sky-400" : "",
// 활성: 흰 배경 + 진한 텍스트 + 하늘색 테두리(위/좌/우) + 아래는 열어둠
isActive
? [
"bg-[#eaf4f4] text-black",
"border-t-2 border-l-2 border-r-2 border-sky-400",
"border-b-0", // 아래는 컨텐츠와 이어지도록 열어둠
"z-10", // 테두리가 위로 올라오게
].join(" ")
: "",
].join(" ")}
>
{t.label}
{/* 활성 탭 하단에 얇은 라인이 필요하면(이미지처럼) */}
{isActive && (
<span className="pointer-events-none absolute left-0 right-0 bottom-[-1px] h-[2px] bg-[#eaf4f4]" />
)}
</button>
);
})}
</div>
</div>
</div>
{/* 슬라이더 */}
<div ref={wrapRef} className="mx-auto max-w-[1520px] px-6 pb-5 pt-10 bg-[#eaf4f4]">
<div className="relative">
{/* 좌우 버튼 */}
<button
type="button"
onClick={() => go(-1)}
disabled={!canSlide || busy}
className={[
"absolute left-0 top-1/2 z-10 -translate-y-1/2",
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
"grid place-items-center border-2 border-[#C2CAD2]",
!canSlide || busy
? "opacity-40 cursor-not-allowed"
: "hover:brightness-95",
].join(" ")}
aria-label="이전"
>
<img
src="/images/landing/icon_left_arrow.png"
alt="이전"
className="w-6 h-6 object-contain"
/>
</button>
<button
type="button"
onClick={() => go(1)}
disabled={!canSlide || busy}
className={[
"absolute right-0 top-1/2 z-10 -translate-y-1/2",
"w-[80px] h-[80px] rounded-full bg-white shadow-[0_10px_25px_rgba(0,0,0,0.12)]",
"grid place-items-center border-2 border-[#C2CAD2]",
!canSlide || busy
? "opacity-40 cursor-not-allowed"
: "hover:brightness-95",
].join(" ")}
aria-label="다음"
>
<img
src="/images/landing/icon_right_arrow.png"
alt="다음"
className="w-6 h-6 object-contain"
/>
</button>
{/* 중요: clip(가림)과 measure(측정) 분리 */}
<div className="overflow-hidden [clip-path:inset(0)] [contain:paint]">
<div className="w-[1250px] overflow-hidden m-auto">
{/* 측정은 패딩 없는 박스에서 */}
<div ref={clipRef} className="w-full">
{/* track */}
<div className="flex" style={trackStyle} onTransitionEnd={onTransitionEnd}>
{viewGroups.map((group, gi) => (
<div
key={`pane-${gi}`}
className="shrink-0"
style={{ width: clipWidth || "100%" }}
>
{/* 그룹 내부: perPage 컬럼 */}
<div
className="grid gap-7"
style={{
gridTemplateColumns: `repeat(${perPage}, minmax(0, 1fr))`,
}}
>
{(group ?? []).map((c) => (
<article
key={c.id}
className="overflow-hidden rounded-[22px] border-2 border-sky-400 bg-white"
>
<div className="h-[241px] w-full bg-slate-100 border-b border-b-blue-300">
<img
src={c.imageSrc}
alt={c.title}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
<div className="p-8">
<h3 className="h-[77px] text-center text-[28px] font-extrabold leading-snug tracking-tight">
{c.title}
</h3>
<p className="mt-4 line-clamp-3 text-[16px] leading-7 text-slate-600">
{c.desc}
</p>
<a
href={c.href ?? "#"}
className="mt-8 inline-flex h-[56px] w-full items-center justify-center gap-3 rounded-md bg-[#02A0D6] text-[18px] font-bold text-white transition hover:brightness-110"
>
자세히 보기 <span aria-hidden>→</span>
</a>
</div>
</article>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* 도트 */}
<div className="mt-8 flex items-center justify-center gap-3">
{Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => (
<button
key={`dot-${i}`}
type="button"
onClick={() => goDot(i)}
className={[
"h-[10px] w-[10px] rounded-full transition",
i === cur
? "bg-black opacity-100"
: "bg-black opacity-30 hover:opacity-60",
].join(" ")}
aria-label={`${i + 1}번 그룹`}
/>
))}
</div>
</div>
</div>
</section>
);
}
+167
View File
@@ -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 (
<section className={className}>
<div className={`mx-auto ${maxWidthClassName}`}>
<div className="relative">
{/* 좌측 버튼 */}
<button
type="button"
onClick={() => go(-1)}
className="absolute left-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_left_arrow.png"
alt="이전"
className="w-[12px] h-[22px]"
/>
</button>
{/* 우측 버튼 */}
<button
type="button"
onClick={() => go(1)}
className="absolute right-[-60px] top-1/2 -translate-y-1/2 z-10
w-[80px] h-[80px] rounded-full border border-black/20 bg-white
grid place-items-center shadow-sm hover:bg-black/[0.03]"
>
<img
src="/images/landing/icon_right_arrow.png"
alt="다음"
className="w-[12px] h-[22px]"
/>
</button>
{/* 뷰포트 */}
<div
className={[
"overflow-hidden",
"select-none touch-pan-y",
"cursor-grab active:cursor-grabbing",
"w-[583px] h-[400px] mx-auto",
].join(" ")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className="flex transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${idx * 100}%)` }}
>
{safeSlides.map((s, i) => (
<div key={`${s.src}-${i}`} className="min-w-full">
<img
src={s.src}
alt={s.alt ?? `slide-${i + 1}`}
className="w-full h-full object-contain"
draggable={false}
/>
</div>
))}
</div>
</div>
</div>
{/* 도트 */}
{canSlide && (
<div className="mt-3 flex items-center justify-center gap-2">
{safeSlides.map((_, i) => {
const active = i === idx;
return (
<button
key={i}
type="button"
onClick={() => setIdx(i)}
aria-label={`${i + 1}번 슬라이드`}
className={[
"h-2 w-2 rounded-full",
active ? "bg-black/70" : "bg-black/20 hover:bg-black/35",
].join(" ")}
/>
);
})}
</div>
)}
</div>
</section>
);
}

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