dm_new menu

This commit is contained in:
dongsilove
2026-02-18 22:11:12 +09:00
parent f621f4dfa1
commit 7a91fcbd80
74 changed files with 9010 additions and 13 deletions
+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>
);
}
+519
View File
@@ -0,0 +1,519 @@
"use client";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
/**
* CaseTabsCarousel.tsx (FINAL OPTIMIZED)
* - 탭별 데이터
* - 카드 perPage(1/2/3) 반응형
* - 카드 perPage개 = 1그룹, 좌/우 클릭 시 1그룹 단위 이동
* - 3그룹 가상화(prev/cur/next)로 DOM 최소화
* - px 기반 트랙 이동 + "측정 박스 분리"로 좌/우 비침 방지
* - stale state 제거(방향/인덱스 ref 관리)로 슬라이드 안정성 강화
* - BUGFIX 1) onTransitionEnd 버블링 방지 (target===currentTarget, transform만 처리)
* - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리
*/
type TabKey = "rain" | "reuse" | "sewage" | "global";
type CaseItem = {
id: string;
title: string;
desc: string;
imageSrc: string;
href?: string;
};
const TABS: { key: TabKey; label: string }[] = [
{ key: "rain", label: "빗물 재이용 사례" },
{ key: "reuse", label: "중수 재이용 사례" },
{ key: "sewage", label: "하수 재이용 사례" },
{ key: "global", label: "해외 재이용 사례" },
];
// 실제 데이터로 교체해서 쓰세요(구조 유지)
const MOCK: Record<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: "#",
},
{
id: "sewage-2",
title: "충남보령 하수재이용",
desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...",
imageSrc: "/images/landing/sewage_02.png",
href: "#",
},
{
id: "sewage-3",
title: "여수시 하수처리수 재이용",
desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...",
imageSrc: "/images/landing/sewage_03.png",
href: "#",
},
...Array.from({ length: 6 }).map((_, i) => ({
id: `sewage-x-${i}`,
title: `하수 재이용 사례 ${i + 4}`,
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
imageSrc: "/images/landing/sample_case.png",
href: "#",
})),
],
global: Array.from({ length: 6 }).map((_, i) => ({
id: `global-${i}`,
title: `해외 재이용 사례 ${i + 1}`,
desc: "설명 문구가 들어갑니다. (최대 2~3줄) …",
imageSrc: "/images/landing/sample_case.png",
href: "#",
})),
};
/* ---------------- utils ---------------- */
function chunk<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">
<img
src={c.imageSrc}
alt={c.title}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
<div className="p-8">
<h3 className="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>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import {
fetchAnonymousMenu,
findActiveTopMenu,
hasAnyChild,
isMenuActive,
type MenuItem,
} from "@/app/store/common";
export type LnbItem = {
label: string;
href?: string;
children?: LnbItem[];
};
function findOpenMenuCode(items: MenuItem[], pathname: string): number | null {
const matched = items.find((item) => {
if (isMenuActive(pathname, item)) return true;
return (item.children ?? []).some((child) => isMenuActive(pathname, child));
});
return matched?.menuCd ?? null;
}
export default function Lnb() {
const pathname = usePathname();
const [menus, setMenus] = useState<MenuItem[]>([]);
const [openMenuCode, setOpenMenuCode] = useState<number | null>(null);
useEffect(() => {
let isMounted = true;
(async () => {
try {
const loaded = await fetchAnonymousMenu();
if (!isMounted) return;
console.log("Fetched LNB menus:", loaded);
setMenus(loaded);
} catch (error) {
console.error("Failed to load DN LNB menu", error);
if (!isMounted) return;
setMenus([]);
}
})();
return () => {
isMounted = false;
};
}, []);
const currentTopMenu = useMemo(
() => findActiveTopMenu(menus, pathname),
[menus, pathname],
);
const secondLevelMenus = useMemo(
() => currentTopMenu?.children ?? [],
[currentTopMenu],
);
// console.log("LNB render", { pathname, currentTopMenu, secondLevelMenus, menus });
useEffect(() => {
const defaultOpen = findOpenMenuCode(secondLevelMenus, pathname);
setOpenMenuCode(defaultOpen);
}, [pathname, secondLevelMenus]);
return (
<aside className="w-[260px]">
<div className="flex h-[120px] items-center justify-center bg-gradient-to-r from-cyan-500 to-green-500 text-[40px] font-bold text-white">
{currentTopMenu?.menuKornNm ?? ""}
</div>
<ul>
{secondLevelMenus.map((item) => {
const hasChildren = hasAnyChild(item);
const active = isMenuActive(pathname, item);
if (!hasChildren) {
return (
<li key={item.menuCd}>
<Link
href={item.urlPath || "#"}
className={[
"block border-b border-slate-300 px-5 py-4 text-center text-base font-semibold transition-colors",
active
? "cursor-default bg-white text-sky-500"
: "bg-[#F0F0F0] text-[#767676] hover:bg-white hover:text-sky-500",
].join(" ")}
>
{item.menuKornNm}
</Link>
</li>
);
}
const isOpen = openMenuCode === item.menuCd;
const childMenus = item.children ?? [];
return (
<li key={item.menuCd}>
<button
type="button"
onClick={() =>
setOpenMenuCode((prev) => (prev === item.menuCd ? null : item.menuCd))
}
className={[
"flex w-full items-center justify-between border-b border-slate-300 px-5 py-6 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.menuKornNm}</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="menu toggle icon"
className="h-7 w-7"
/>
</span>
</button>
<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>
{childMenus.map((child) => {
const childActive = isMenuActive(pathname, child);
return (
<li key={child.menuCd}>
<Link
href={child.urlPath || "#"}
className={[
"block border-b border-slate-200 bg-slate-50 px-5 py-4 pl-[60px] text-[18px] font-medium transition-colors",
childActive
? "cursor-default bg-white text-sky-500"
: "text-slate-500 hover:bg-white hover:text-sky-500",
].join(" ")}
>
<span className="mr-2 text-current">-</span>
{child.menuKornNm}
</Link>
</li>
);
})}
</ul>
</div>
</div>
</li>
);
})}
</ul>
</aside>
);
}
+320
View File
@@ -0,0 +1,320 @@
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
type LoginModalProps = {
open: boolean;
onClose: () => void;
onSuccess?: (payload: { userName: string; accessToken?: string }) => void;
};
type FieldKey = "bizNo" | "workerNo" | "pw";
const REGISTER_HREF = "/reuse/apply/place";
function isBizNoValid(v: string) {
return /^\d{3}-\d{2}-\d{5}$/.test(v);
}
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
const base = "h-[48px] w-full 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 ring-1 ring-red-400 focus:border-red-500`;
return `${base} border-gray-300 focus:border-sky-400`;
}
export default function LoginModal({ 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 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-center text-[32px] font-bold tracking-tight leading-none text-gray-900"
>
수요처 로그인
</h2>
{/* title underline gradient bar */}
<div className="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>
);
}
+64
View File
@@ -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="text-[28px] font-extrabold tracking-tight text-slate-900 leading-none">{title}</h1>
<div className="h-2 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>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useId, useMemo, useState } from "react";
import LoginModal from "@/components/LoginModal";
import { fetchAnonymousMenu, isMenuActive, type MenuItem } from "@/app/store/common";
const REGISTER_HREF = "/reuse/apply/place";
const LS_TOKEN_KEY = "reuse_accessToken";
const LS_USER_KEY = "reuse_userName";
const DEFAULT_NAV_ITEMS: Pick<MenuItem, "menuCd" | "menuKornNm" | "urlPath" | "newWndwYn">[] = [
{ menuCd: 1, menuKornNm: "Intro", urlPath: "/reuse/intro/law", newWndwYn: "N" },
{ menuCd: 2, menuKornNm: "Status", urlPath: "/reuse/cases/rain", newWndwYn: "N" },
{ menuCd: 3, menuKornNm: "Business", urlPath: "/reuse/business/rain/suwon", newWndwYn: "N" },
{ menuCd: 4, menuKornNm: "Demand", urlPath: "/reuse/apply/place", newWndwYn: "N" },
];
export default function TopNav() {
const router = useRouter();
const pathname = usePathname();
const [loginOpen, setLoginOpen] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
const [topMenus, setTopMenus] = useState<MenuItem[]>([]);
useEffect(() => {
const name = localStorage.getItem(LS_USER_KEY);
setUserName(name);
}, []);
useEffect(() => {
let isMounted = true;
(async () => {
try {
const loaded = await fetchAnonymousMenu();
if (!isMounted) return;
const level1 = loaded.filter((menu) => menu.levelNo === 1 || menu.parentId === null);
setTopMenus(level1);
} catch (error) {
console.error("Failed to load DN top menu", error);
if (!isMounted) return;
setTopMenus([]);
}
})();
return () => {
isMounted = false;
};
}, []);
const navItems = useMemo(() => {
if (topMenus.length > 0) return topMenus;
return DEFAULT_NAV_ITEMS.map((item) => ({
...item,
parentId: null,
sortSeq: 0,
children: [],
levelNo: 1,
useYn: "Y",
otptYn: "Y",
}));
}, [topMenus]);
const openLogin = () => setLoginOpen(true);
const logout = () => {
localStorage.removeItem(LS_TOKEN_KEY);
localStorage.removeItem(LS_USER_KEY);
setUserName(null);
router.refresh();
};
return (
<>
<header className="fixed left-0 right-0 top-0 z-50 border-b bg-white shadow-md backdrop-blur">
<div className="px-6">
<div className="relative flex h-[83px] items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/main" 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 flex -translate-x-1/2 items-center gap-[40px] text-sm font-medium">
{navItems.map((item) => {
const requiresLogin = (item.urlPath)? item.urlPath.startsWith("/reuse/apply") : false;
const active = isMenuActive(pathname, item);
const className = [
"relative px-3 py-2 text-lg font-bold",
active ? "text-sky-700" : "text-gray-800 hover:text-sky-700",
].join(" ");
if (requiresLogin && !userName) {
return (
<button
key={item.menuCd}
type="button"
onClick={openLogin}
className={className}
>
{item.menuKornNm}
</button>
);
}
if (item.newWndwYn === "Y") {
return (
<button
key={item.menuCd}
type="button"
onClick={() => window.open(item.urlPath, "_blank")}
className={className}
>
{item.menuKornNm}
</button>
);
}
return (
<Link key={item.menuCd} href={item.urlPath || "#"} className={className}>
{item.menuKornNm}
</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"
>
Login
<span aria-hidden>
<img
src="/images/landing/icon_login.png"
alt="login icon"
className="ml-1 h-6 w-6 object-contain"
/>
</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"
>
Logout
<span aria-hidden>
<img
src="/images/landing/icon_logout.png"
alt="logout icon"
className="h-6 w-6 object-contain"
/>
</span>
</button>
</>
)}
</div>
</div>
</div>
</header>
<LoginModal
open={loginOpen}
onClose={() => setLoginOpen(false)}
onSuccess={({ userName: nextUserName, accessToken }) => {
localStorage.setItem(LS_USER_KEY, nextUserName);
if (accessToken) localStorage.setItem(LS_TOKEN_KEY, accessToken);
setUserName(nextUserName);
setLoginOpen(false);
router.refresh();
}}
/>
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
//8880
import { AUTH_USER_ID, CLAIM_NAME } from "@/src/shared/config";
import { getCookie } from "@common/components/lib/cookie";
const tokenId = getCookie(AUTH_USER_ID);
const Authorization = getCookie(CLAIM_NAME);
export const fetchType = {
COMMON: "common",
WR: "wr",
FO: "fo",
SN: "sn"
};
const API_VERSION = "/api/v1"
const servers = {
common: "",
local: "",
fo: "/operation-service" + API_VERSION,
wr: "/reuse-service" + API_VERSION,
sn: "/sewerage-service" + API_VERSION,
}
interface RequestParams {
[key: string]: unknown;
}
export const getFetch = async (type: string, url: string) => {
const baseUrl = servers[type];
const response = await fetch(baseUrl + url, {
method: "GET",
headers: {
accept: "application/json",
[AUTH_USER_ID]: tokenId ?? "",
[CLAIM_NAME]: Authorization ?? "",
},
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
const res = await response.json();
return res;
};
export const postFetch = async (url: string, params: RequestParams) => {
const baseUrl = servers[fetchType.WR];
const response = await fetch(baseUrl + url, {
method: "POST",
headers: {
"Content-Type": "application/json",
[AUTH_USER_ID]: tokenId ?? "",
[CLAIM_NAME]: Authorization ?? "",
},
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return response;
};
export const putFetch = async (url: string, params: RequestParams) => {
const baseUrl = servers[fetchType.WR];
const response = await fetch(baseUrl + url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
[AUTH_USER_ID]: tokenId ?? "",
[CLAIM_NAME]: Authorization ?? "",
},
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return response;
};
export const deleteFetch = async (url: string, params: RequestParams) => {
const baseUrl = servers[fetchType.WR];
const response = await fetch(baseUrl + url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
[AUTH_USER_ID]: tokenId ?? "",
[CLAIM_NAME]: Authorization ?? "",
},
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return response;
};
@@ -0,0 +1,103 @@
import { useDmStore } from '@/app/store';
import type { DemandCompType } from '@common/business_components/ui/sdmInsertForm/form';
import React, { useCallback, useEffect, useRef, useState } from 'react';
type thisProps = {
label: string | undefined;
initValue: string | undefined;
setValue : (dmdCoNm?: string) => void;
setRtnData : (rtnData: DemandCompType[]) => void;
}
export default function SearchInputText({label=undefined, initValue=undefined, setValue=()=>{}, setRtnData=()=>{}} :thisProps) {
const { getDemandCompList, Sdm } = useDmStore((state) => state);
const {
demandCompList
} = Sdm;
const [isOpen, setIsOpen] = useState(false);
const [textValue, setTextValue] = useState(initValue || '');
const wrapperRef = useRef<HTMLDivElement>(null);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setTextValue(value);
setValue(value);
if (value) {
setIsOpen(true);
getDemandCompList(value, 20);
} else {
setIsOpen(false);
}
}, [getDemandCompList, setValue]);
const handleSuggestionClick = useCallback((dmdCoNm: string) => {
setTextValue(dmdCoNm);
setValue(dmdCoNm);
setIsOpen(false);
setRtnData(demandCompList);
}, [demandCompList, setRtnData, setValue]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div ref={wrapperRef}>
<input
type="text"
value={textValue}
onChange={handleInputChange}
/* onFocus={() => setIsOpen(true)} */
className="w-full h-[3.2rem] !text-xl pl-3 text-gray-600 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`${label}을(를) 입력하세요`}
/>
{isOpen && (
<div className=" z-1 w-full mt-2 bg-white border border-gray-300 rounded-md shadow-lg h-90 overflow-auto">
<div className="max-h-72">
{demandCompList && demandCompList.length > 0 ? demandCompList.map((item : DemandCompType, index : number) => (
<div
key={index}
className="!text-xl w-full text-left px-5 py-3 hover:bg-gray-100 cursor-pointer overflow-auto"
onClick={() => handleSuggestionClick(item.dmdCoNm)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSuggestionClick(item.dmdCoNm);
}
}}
>
{item.dmdInfoId.substring(10,14) !== '0000' ? (
<>
<span className="text-gray-500">사업자번호 : </span> {item.dmdInfoId.substring(0,10)}&nbsp;
<span className="text-gray-500">종사업자번호 : </span> {item.dmdInfoId.substring(10,14)}&nbsp;
</>
) : (
<>
<span className="text-gray-500">사업자번호 : </span> {item.dmdInfoId.substring(0,10)}&nbsp;
</>
)}
<span className="float-right">{item.dmdCoNm}</span>
</div>
)) : (
<div className="!text-xl w-full px-5 py-3 hover:bg-gray-100 cursor-pointer overflow-auto">
검색 결과가 없습니다.
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,54 @@
export const sanitizeInput = (val: string, allowDecimal = false) => {
if (val === "") return ""; // 빈 값 허용
// 소숫점 허용
if (allowDecimal) {
// 숫자와 점만 허용
let cleaned = val.replace(/[^0-9.]/g, "");
// 소숫점 여러 개일 경우 첫 번째만 유지
const parts = cleaned.split(".");
if (parts.length > 2) {
cleaned = parts[0] + "." + parts.slice(1).join("");
}
return cleaned;
}
// 정수만 허용
return val.replace(/[^0-9]/g, "");
};
// utils: format Date <-> "YYYY-MM-DD" without moment
export const formatDateToYMD=(value: Date): string => {
if (!value) return "";
// value can be Date | string ("YYYY-MM-DD")
if (typeof value === "string") {
// assume already in yyyy-mm-dd
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
const d = new Date(value);
if (isNaN(d.getTime())) return "";
return toYMD(d);
}
if (value instanceof Date) {
if (isNaN(value.getTime())) return "";
return toYMD(value);
}
return "";
}
const toYMD=(date: Date): string => {
// avoid timezone shift by normalizing to local date parts
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
// const parseYMDToDate=(ymd: string): Date | null => {
// if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return null;
// const [y, m, d] = ymd.split("-").map(Number);
// const date = new Date(y, (m - 1), d);
// // validate round-trip
// return (date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d) ? date : null;
// }
@@ -0,0 +1,6 @@
import { PROXY_HOST } from "@/src/shared/config/env";
export const LOCAL_URL = PROXY_HOST;
export const DEV_URL_FO = "/operation-service";
export const DEV_URL_WR = "/reuse-service";
export const DEV_URL_SN = "/sewerage-service";
@@ -0,0 +1,139 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
type SearchType = {
bscFctNm: string;
bscFctCd: string;
};
type thisProps = {
label: string | undefined;
initValue: string | undefined;
setValue: (searchNm?: string) => void;
setRtnData: (rtnData: any) => void;
searchList?: SearchType[];
};
export default function SearchInputText({
label = undefined,
initValue = undefined,
setValue = () => {},
setRtnData = () => {},
searchList = [],
}: thisProps) {
const [isOpen, setIsOpen] = useState(false);
const [textValue, setTextValue] = useState(initValue || "");
const [highlightIndex, setHighlightIndex] = useState<number>(-1);
const wrapperRef = useRef<HTMLDivElement>(null);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setTextValue(value);
setValue(value);
setHighlightIndex(-1);
setIsOpen(value !== "");
},
[setValue]
);
const handleSuggestionClick = useCallback(
(searchNm: string) => {
setValue(searchNm);
setTextValue(searchNm);
setIsOpen(false);
setRtnData(searchList?.find((item) => item.bscFctNm === searchNm) || {});
},
[searchList, setRtnData, setValue]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen || searchList.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((prev) => (prev + 1) % searchList.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((prev) =>
prev <= 0 ? searchList.length - 1 : prev - 1
);
} else if (e.key === "Enter") {
e.preventDefault();
if (searchList.length === 1 && searchList[0]) {
handleSuggestionClick(searchList[0].bscFctNm);
} else if (highlightIndex >= 0 && highlightIndex < searchList.length && searchList[highlightIndex]) {
handleSuggestionClick(searchList[highlightIndex].bscFctNm);
}
}
},
[highlightIndex, isOpen, searchList, handleSuggestionClick]
);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<div ref={wrapperRef} className="relative">
<input
type="text"
value={textValue || ""}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
className="w-full h-[3.2rem] !text-xl pl-3 text-gray-600 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`${label}을(를) 입력하세요`}
onClick={() => {
setTextValue("");
setValue("");
setIsOpen(false);
}}
/>
{isOpen && (
<div className="absolute top-full left-0 z-50 mb-2 max-h-72 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
<div className="max-h-72">
{searchList.length > 0 ? (
searchList.map((item, index) => (
<div
key={index}
className={`w-full cursor-pointer overflow-auto px-5 py-3 text-left !text-xl ${
highlightIndex === index
? "bg-blue-100"
: "hover:bg-gray-100"
}`}
onClick={() => handleSuggestionClick(item.bscFctNm)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleSuggestionClick(item.bscFctNm);
}
}}
>
<span className="float-left">{item.bscFctNm}</span>
</div>
))
) : (
<div className="!text-xl w-full px-5 py-3 hover:bg-gray-100 cursor-pointer overflow-auto">
검색 결과가 없습니다.
</div>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useDmStore } from "@/app/store";
import {
SelectBox,
type SelectDataType,
} from "@common/business_components/ui";
import { Button, Label } from "@common/components/ui";
import { useCallback, useEffect, useState } from "react";
import SearchInputText from "../SearchInputText/SearchInputText";
export function SearchBar({
setHasuPk,
onApply,
}: {
setHasuPk: (hasuPk: any) => void;
onApply: () => void;
}) {
const { map, Common, initializeCommonData, findRuseFclt } = useDmStore(
(state) => state
);
const [sidoValue, setSidoValue] = useState<string>("");
const [sigunValue, setSigunValue] = useState<string>("");
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
{ text: "전체", val: "0" },
]);
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
const initializeSigunData = useCallback(
(sidoCd: string) => {
if (sidoCd === "0") {
setSigunValue("0");
setFilteredSigunData([{ text: "전체", val: "0" }]);
} else {
const newFilteredData = [
{ text: "전체", val: "0" },
...Common.selectSigunData.filter(
(item: SelectDataType) => item.val.substring(0, 2) === sidoCd
),
];
setSigunValue("0");
setFilteredSigunData(newFilteredData);
}
},
[Common.selectSigunData]
);
const onSelectValue = useCallback(
(val: string, type: string) => {
switch (type) {
case "ctpvCd":
initializeSigunData(val);
setSidoValue(val);
break;
case "sggCd":
setSigunValue(val);
}
},
[initializeSigunData]
);
return (
<div className="flex justify-center w-full px-4 py-3">
<div className="flex items-center w-full max-w-screen-lg rounded-xl bg-white shadow-md px-6 py-4 space-x-3">
<SelectBox
labelClass="w-[40px]"
className="w-[200px]"
label="시도"
selectClass="rounded-lg bg-white"
selectArray={Common.selectSidoData || []}
onSelectValue={(val) => onSelectValue(val.toString(), "ctpvCd")}
value={sidoValue || Common.selectSidoData[0]?.val || ""}
/>
<SelectBox
labelClass="w-[65px]"
className="w-[200px]"
label="시군구"
selectClass="rounded-lg bg-white"
selectArray={filteredSigunData || []}
onSelectValue={(val) => onSelectValue(val.toString(), "sggCd")}
value={sigunValue}
/>
<Label className="text-gray-600">하수재이용시설</Label>
<SearchInputText
label="시설명"
initValue={undefined}
setValue={(searchNm?: string) => {
let sggOrCity = "";
if (searchNm) {
if (sigunValue === "0" || sigunValue === "") {
sggOrCity = sidoValue;
} else {
sggOrCity = sigunValue;
}
findRuseFclt(sggOrCity, searchNm);
}
}}
setRtnData={(rtnData: any) => {
setHasuPk(rtnData);
}}
searchList={map.ruseFcltList || []}
/>
<Button
color="sky"
className="ml-2 text-white"
size="sm"
onClick={() => {
onApply();
}}
>
적용
</Button>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { FormUi } from "@common/business_components/ui";
import type { ControllerRenderProps, FieldError, FieldValues, Path } from "react-hook-form";
import { cloneElement, isValidElement } from "react";
interface AccessibleFormFieldProps<T extends FieldValues> {
field: ControllerRenderProps<T, Path<T>>;
label: string;
required?: boolean;
error?: FieldError;
isDisabled?: boolean;
inputComponent: React.ReactElement<{ disabled?: boolean }>;
}
export function AccessibleFormField<T extends FieldValues>({
label,
required = false,
inputComponent,
isDisabled = false,
}: AccessibleFormFieldProps<T>) {
const labelText = required ? `${label} *` : label;
return (
<FormUi label={labelText} isDisabled={isDisabled}>
<div>
{isValidElement(inputComponent)
? cloneElement(inputComponent, { disabled: isDisabled } as Partial<typeof inputComponent.props>)
: inputComponent}
{/* {error?.message && Array.from(new Set(error.message.split('\n'))).map((msg, idx) => (
<p key={idx} className="text-red-500 text-sm mt-1" role="alert">
{msg}
</p>
))} */}
</div>
</FormUi>
);
}
@@ -0,0 +1,962 @@
import ModalSimilarPage from "@/app/(base-layout)/pages/sdmMng/sdmInfoInsert/modalSimilar/page";
import { useDmStore } from "@/app/store";
import {
CANDIDATE,
// DatePicker,
FormField,
FormLabel,
FormUi,
SelectBox,
UiModal,
} from "@common/business_components";
import { Input } from "@common/components";
import { Button, Label } from "@common/components/ui";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { AccessibleFormField } from "./AccessibleFormField";
import {
type ControllerRenderProps,
type UseFormReturn,
type FieldError,
useWatch,
} from "react-hook-form";
import SearchInputText from "../common/searhDemandInput/component";
import React from "react";
import { formatDateToYMD, sanitizeInput } from "../common/utils/utils";
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import type { SdmInfoFormData } from "@/app/store/sdmMng";
export interface SdmInfoRegFormProps {
form: UseFormReturn<SdmInfoFormData>;
onCheckboxChange?: (checked: boolean) => void;
facilityName?: string;
}
type FieldComponent = (
field: ControllerRenderProps<SdmInfoFormData, keyof SdmInfoFormData>
) => React.ReactElement<{ disabled?: boolean }>;
export function SdmInfoRegForm({
form,
onCheckboxChange,
facilityName,
}: SdmInfoRegFormProps) {
const { initializeCommonData, Sdm } = useDmStore((state) => state);
const { useTypesData, defaultRules, disableRules } = Sdm;
const searchParams = useSearchParams();
const [sdmNmValue, setSdmNmValue] = useState<string>(
searchParams.get("dmdCoNm") || ""
);
const [frontPHdata, setFrontPHdata] = useState("0");
const [backPHdata, setBackPHdata] = useState("0");
const [isBznmTotNoDisabled, setIsBznmTotNoDisabled] = useState(false);
const [isSimilarCase, setIsSimilarCase] = useState(false);
const [key, setKey] = useState(0);
// 후보지 팝업 오픈 상태
//모달 조회에 필요한 데이터
const [sply, setSply] = useState<number>(0);
const [usageCd, setUsageCd] = useState<string>("청소");
const [money, setMoney] = useState<number>(0);
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
useEffect(() => {
console.log("candidateTitle ", form.getValues("candidateTitle"));
}, [form.getValues]);
useEffect(() => {
if (useTypesData && useTypesData.length > 0) {
setTimeout(() => {
form.setValue("usePurpose", useTypesData[0]?.val?.toString() || "");
}, 500);
}
}, [useTypesData, form.setValue, form]);
useEffect(() => {
if (facilityName) {
setSdmNmValue(facilityName);
form.setValue("sdmNm", facilityName);
setKey((prev) => prev + 1);
}
}, [facilityName, form.setValue, form]);
useEffect(() => {
if (searchParams.get("dmdCoNm")) {
form.setValue("sdmNm", searchParams.get("dmdCoNm") || "");
setSdmNmValue(searchParams.get("dmdCoNm") || "");
setKey((prev) => prev + 1);
}
}, [searchParams, form.setValue, form]);
const searchCandidate = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const dmdInfoId = JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdInfoId?.toString();
const url = `/map/popup?dmdInfoId=${encodeURIComponent(dmdInfoId)}`;
const width = window.screen.availWidth;
const height = window.screen.availHeight;
const left = 0;
const top = 0;
const popupOptions = `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
window.open(url, "candidatePopup", popupOptions);
};
const searchSimilarCase = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsSimilarCase(true);
};
const handleCheckboxChange = (checked: boolean) => {
setIsBznmTotNoDisabled(checked);
onCheckboxChange?.(checked);
};
useEffect(() => {
const watchPH = form.getValues(`ph`);
if (watchPH && watchPH !== undefined) {
const [frontPH, backPH] = watchPH.split("~");
if (frontPH) {
setFrontPHdata(frontPH);
}
if (backPH) {
setBackPHdata(backPH);
}
} else {
setFrontPHdata("0");
setBackPHdata("0");
}
}, [form.getValues(`ph`)]);
const isSetPh = useCallback(
(typePHdata: string, index: number) => {
const PHdata = form.getValues("ph");
const isPH = PHdata?.split("~")[index] || "";
if (index == 0) {
form.setValue("ph", `${isPH}~${typePHdata}`);
setBackPHdata(typePHdata);
} else if (index == 1) {
form.setValue("ph", `${typePHdata}~${isPH}`);
setFrontPHdata(typePHdata);
}
},
[form]
);
const usePurpose = useWatch({ control: form.control, name: "usePurpose" });
useEffect(() => {
if (!usePurpose) return;
// Apply default values
const defaults = defaultRules[usePurpose];
if (defaults) {
Object.entries(defaults).forEach(([field, value]) => {
form.setValue(field as keyof SdmInfoFormData, value, {
shouldDirty: true,
});
});
}
}, [form.getValues("usePurpose")]);
// 시설 선택시 이벤트
const selectComp = useCallback(
(rtnData: DemandCompType[]) => {
const rtnCompData = rtnData?.[0];
form.setValue("sdmNm", rtnCompData?.dmdCoNm || "");
},
[form]
);
const formFields: Array<{
name: keyof SdmInfoFormData;
label: string;
component: FieldComponent | undefined;
formIndex: number;
required?: boolean;
}> = [
{
name: "sdmNm",
label: "수요처선택",
formIndex: 0,
required: true,
component: (field) => (
<div key={key}>
<SearchInputText
label="수요처명"
setValue={(val) => {
setSdmNmValue(val || "");
field.onChange(val || "");
}}
setRtnData={selectComp}
initValue={facilityName || sdmNmValue}
{...field}
/>
</div>
),
},
{
name: "demandYmd",
label: "요구시기",
formIndex: 0,
required: true,
component: (field) => (
<div className="relative w-64">
{/* <DatePicker
mode="single"
selected={field.value as Date}
onSelect={(date) => {
field.onChange(date as Date);
}}
className=""
//defaultMonth={field.value as Date}
field={{
value: field.value as Date,
onChange: (date: Date | undefined) => {
field.onChange(date as Date);
},
}}
showOutsideDays={true}
selectClass={undefined}
/> */}
{/* 네이티브 input */}
<Input
className="w-full !text-xl text-right"
type="date"
onChange= {(e) => {
const selectedDate = e.target.value ? new Date(e.target.value) : null;
field.onChange(selectedDate);
}}
value={ formatDateToYMD(field.value as Date) }
/>
</div>
),
},
{
name: "demandQnty",
label: "요구수량(㎥/d)",
formIndex: 0,
required: true,
component: (field) => (
<Input
type="text"
className="!text-xl text-gray-600"
value={field.value as string}
onChange={(e) => {
field.onChange(e);
setSply(Number(e.target.value));
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
),
},
{
name: "usePurpose",
label: "사용용도",
formIndex: 0,
required: true,
component: (field) => (
<SelectBox
selectClass={undefined}
label=""
selectArray={useTypesData}
onSelectValue={(val) => {
field.onChange(val.toString());
setUsageCd(val.toString());
}}
className=""
value={field.value as string}
/>
),
},
{
name: "money",
label: "희망단가(원)",
formIndex: 0,
required: true,
component: (field) => (
<FormUi
onCheckboxChange={handleCheckboxChange}
isDisabled={isBznmTotNoDisabled}
>
<Input
type="text"
className="!text-xl text-gray-600"
value={field.value as string}
onChange={(e) => {
field.onChange(e);
setMoney(Number(e.target.value));
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormUi>
),
},
{
name: "reqWaterQlty",
label: "요구수질",
formIndex: 0,
required: true,
component: undefined,
},
{
name: "candidateTitle",
label: CANDIDATE || "공급처 후보지 선택(필수아님)",
formIndex: 2,
component: () => (
<div>
<div className="-mt-14 float-right flex gap-10">
<Button
className="rounded-md"
color="sky"
size={"sm"}
onClick={searchCandidate}
>
후보지 조회
</Button>
<Button
className="rounded-md"
color="sky"
size={"sm"}
onClick={searchSimilarCase}
>
유사사례
</Button>
</div>
<div className="flex justify-center gap-10 mt-5">
{(form.getValues("candidateTitle") || []).map(
(candidate: any, index: number) => (
<Input
key={index}
placeholder={`후보지${index + 1}`}
type="text"
className="!text-xl w-[130px] text-gray-600"
value={candidate.ruseUtztnFcltNm}
readOnly
/>
)
)}
</div>
</div>
),
},
];
const normalFields = formFields.filter(
(f) => f.formIndex === 0 && f.component
);
const candidateFields = formFields.filter(
(f) => f.formIndex === 2 && f.component
);
return (
<>
{normalFields.map(({ name, label, component, required }) =>
component ? (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) =>
name === "money" ? (
<div>
<Label className="text-black">희망단가(원)*</Label>
{component(field)}
</div>
) : (
// <FormUi label={label + "*"}>{component(field)}</FormUi>
<AccessibleFormField
field={field}
label={label}
required={required}
error={
typeof form.formState.errors[name]?.message === "string"
? (form.formState.errors[name] as FieldError)
: undefined
}
inputComponent={component(field)}
/>
)
}
/>
) : null
)}
<div className="mt-10">
<FormLabel className="text-xl">요구수질</FormLabel>
<div className="grid grid-cols-2 gap-x-10 gap-y-6 mt-3 mb-3">
<FormField
control={form.control}
name={`grssClfg`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi
label={
!isDisabled ? `총대장균군수(개/mg)*` : "총대장균군수(개/mg)"
}
>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const inputValue = e.target.value.replace(
/[^0-9]/g,
""
); // 정수만
field.onChange(
inputValue === "" ? null : Number(inputValue)
);
}}
value={
field.value !== undefined && field.value !== null
? `${field.value}`
: "불검출"
}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
{/* TODO: 총대장균군수 확인 필요*/}
{field.value !== null ? (
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
) : (
<></>
)}
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`tn`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi
label={
!isDisabled ? `총질소(T-N)(mg/L)*` : "총질소(T-N)(mg/L)"
}
>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const inputValue = e.target.value.replace(
/[^0-9]/g,
""
); // 정수만
field.onChange(
inputValue === "" ? null : Number(inputValue)
);
}}
value={
field.value !== undefined && field.value !== null
? `${field.value}`
: ""
}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`combResdch`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi
label={
!isDisabled ? `결합잔류염소(mg/L)*` : "결합잔류염소(mg/L)"
}
>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const cleanValue = sanitizeInput(
e.target.value,
true
);
field.onChange(
cleanValue === "" ? null : Number(cleanValue)
);
}}
value={field.value ?? ""}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이상
</span>
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`tph`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi
label={!isDisabled ? `총인(T-P)(mg/L)*` : "총인(T-P)(mg/L)"}
>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const cleanValue = sanitizeInput(
e.target.value,
true
);
field.onChange(
cleanValue === "" ? null : Number(cleanValue)
);
}}
value={field.value ?? ""}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`trbt`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi label={!isDisabled ? `탁도(NTU)*` : "탁도(NTU)"}>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const inputValue = e.target.value.replace(
/[^0-9]/g,
""
); // 정수만
field.onChange(
inputValue === "" ? null : Number(inputValue)
);
}}
value={
field.value !== undefined && field.value !== null
? `${field.value}`
: ""
}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
</div>
</div>
</FormUi>
);
}}
/>
<div className="col-span-1">
<Label className="text-xl">수소이온농도(pH)</Label>
<div className="flex flex-row items-center gap-2">
<Input
className="!text-xl w-36"
placeholder=""
type="text"
onChange={(e) => {
const cleanValue = sanitizeInput(e.target.value, true);
isSetPh(cleanValue, 1);
}}
value={frontPHdata ?? ""}
maxLength={5}
/>
<span className="text-xl mx-2">~</span>
<Input
className="!text-xl w-36 "
placeholder=""
type="text"
onChange={(e) => {
const cleanValue = sanitizeInput(e.target.value, true);
isSetPh(cleanValue, 0);
}}
value={backPHdata ?? ""}
maxLength={5}
/>
</div>
</div>
<FormField
control={form.control}
name={`bod`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi
label={
!isDisabled
? `생물학적 산소요구량(BOD)(mg/L)*`
: "생물학적 산소요구량(BOD)(mg/L)"
}
>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const inputValue = e.target.value.replace(
/[^0-9]/g,
""
); // 정수만
field.onChange(
inputValue === "" ? null : Number(inputValue)
);
}}
value={
field.value !== undefined && field.value !== null
? `${field.value}`
: ""
}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`clIonQnt`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi
label={!isDisabled ? `염화물(mgCL/L)*` : "염화물(mgCL/L)"}
>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const inputValue = e.target.value.replace(
/[^0-9]/g,
""
); // 정수만
field.onChange(
inputValue === "" ? null : Number(inputValue)
);
}}
value={
field.value !== undefined && field.value !== null
? `${field.value}`
: ""
}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`smll`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi label={!isDisabled ? `냄새*` : "냄새"}>
<SelectBox
className="!text-2xl rounded-md"
label={""}
selectClass={undefined}
selectArray={[
{ text: "불쾌하지않을것", val: "불쾌하지않을것" },
{ text: "해당사항없음", val: "해당사항없음" },
]}
onSelectValue={(val: string | number) => {
field.onChange(val.toString());
}}
value={field.value}
readOnly={isDisabled}
onBlur={field.onBlur}
ref={field.ref}
/>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`cdtnv`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi
label={
!isDisabled ? `전기전도도(㎲/cm)*` : "전기전도도(㎲/cm)"
}
>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const inputValue = e.target.value.replace(
/[^0-9]/g,
""
); // 정수만
field.onChange(
inputValue === "" ? null : Number(inputValue)
);
}}
value={
field.value !== undefined && field.value !== null
? `${field.value}`
: ""
}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`crmty`}
render={({ field }) => {
const disabledFields = usePurpose ? disableRules[usePurpose] : [];
const isDisabled = disabledFields
? disabledFields.includes(field.name)
: true;
return (
<FormUi label={!isDisabled ? `색도(도)*` : "색도(도)"}>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 text-right rounded-md"
placeholder=""
onChange={(e) => {
const inputValue = e.target.value.replace(
/[^0-9]/g,
""
); // 정수만
field.onChange(
inputValue === "" ? null : Number(inputValue)
);
}}
value={
field.value !== undefined && field.value !== null
? `${field.value}`
: ""
}
maxLength={6}
disabled={isDisabled}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
<span className="text-xl absolute inset-y-0 right-3 flex items-center text-gray-500 pointer-events-none">
이하
</span>
</div>
</div>
</FormUi>
);
}}
/>
<FormField
control={form.control}
name={`rmrk`}
render={({ field }) => {
return (
<FormUi label={"비고"}>
<div className="flex items-center gap-2">
<div className="relative flex-1 min-w-0">
<Input
type="text"
className="w-full pr-16 border py-2 rounded-md"
placeholder=""
onChange={(e) => {
field.onChange(e.target.value);
}}
value={
field.value !== undefined && field.value !== ""
? `${field.value}`
: ""
}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</div>
</div>
</FormUi>
);
}}
/>
</div>
</div>
{candidateFields.map(({ name, label, component, required }, index) =>
component ? (
<FormField
key={name + index}
control={form.control}
name={name}
render={({ field }) => (
// <FormUi label={label}>
// {component(field)}
// </FormUi>
<AccessibleFormField
field={field}
label={label}
required={required}
error={
typeof form.formState.errors[name]?.message === "string"
? (form.formState.errors[name] as FieldError)
: undefined
}
inputComponent={component?.(field)}
/>
)}
/>
) : null
)}
{isSimilarCase && (
<UiModal
body={
<ModalSimilarPage sply={sply} usageCd={usageCd} money={money} />
}
bodyClass="sm:max-w-[100vw] w-[1400px] h-[100dvh] max-h-[900px] overflow-y-auto overflow-x-hidden"
open={isSimilarCase}
onOpenChange={(open) => !open && setIsSimilarCase(false)}
noHeaderYn="Y"
noFooterYn="Y"
/>
)}
</>
);
}
@@ -0,0 +1,197 @@
import { useDmStore } from "@/app/store";
import { BZNM_TOT_NO, FormField, FormUi } from "@common/business_components";
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { Button, Input } from "@common/components";
import { zodResolver } from "@hookform/resolvers/zod";
import { sha512 } from 'js-sha512';
import React, { useEffect, useMemo, useState } from "react";
import { FormProvider, useForm, type ControllerRenderProps } from "react-hook-form";
import toast from "react-hot-toast";
import * as z from "zod";
export function SdmLoginForm() {
const { actionLogin, initializeCommonData } = useDmStore((state) => state);
const [isBznmTotNoDisabled, setIsBznmTotNoDisabled] = useState(true);
const formatBusinessNumber = (value: string) => {
// 숫자만 추출
const digitsOnly = value.replace(/\D/g, "");
// 10자리까지만 처리
const limitedDigits = digitsOnly.slice(0, 10);
// 3-2-5 형식으로 포맷팅
if (limitedDigits.length <= 3) {
return limitedDigits;
} else if (limitedDigits.length <= 5) {
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3)}`;
} else {
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3, 5)}-${limitedDigits.slice(5)}`;
}
};
// 스키마를 useMemo로 최적화
const sdmLoginFormSchema = useMemo(() => z.object({
dmdInfoId: z.string()
.min(1, "사업자등록번호를 입력해주세요")
.regex(/^\d{10}$/, "사업자등록번호는 10자리 숫자만 입력 가능합니다"),
bznmTotNo: isBznmTotNoDisabled
? z.string().optional()
: z.string().length(4, "종사업자등록번호는 4자리 숫자만 입력 가능합니다"),
password: z.string()
.min(1, "비밀번호를 입력해주세요")
.min(8, "비밀번호는 8자 이상 입력해주세요")
.max(16, "비밀번호는 16자 이내로 입력해주세요"),
rgtr: z.string().optional(),
dmdAplyPswd: z.string().optional(),
}), [isBznmTotNoDisabled]);
type SdmLoginForm = z.infer<typeof sdmLoginFormSchema>;
type FieldComponent = (field: ControllerRenderProps<SdmLoginForm, keyof SdmLoginForm>) => React.ReactElement;
const formSchema = sdmLoginFormSchema;
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
const defaultValues: SdmLoginForm = {
dmdInfoId: '',
bznmTotNo: '',
password: '',
rgtr: '',
dmdAplyPswd: '',
};
const form = useForm<SdmLoginForm>({
resolver: zodResolver(formSchema),
defaultValues,
mode: "onChange",
});
const handleCheckboxChange = (checked: boolean) => {
setIsBznmTotNoDisabled(checked);
};
const initPassword = () => {
toast(
(t) => (
<div className="flex flex-col items-center">
<div className="text-2xl mb-2">
관리자(031-0000-0000)에게 비밀번호 초기화 요청을 유선으로 하시기 바랍니다.
</div>
<Button size={"sm"} onClick={() => toast.dismiss(t.id)}>
확인
</Button>
</div>
),
{
id: "pwd-reset",
duration: Infinity,
position: "top-center",
}
);
}
const handleLogin = () => {
const data = form.getValues();
const passwordStr = sha512.hex(data.password)
data.dmdAplyPswd = passwordStr;
const param = {
dmdInfoId: data.dmdInfoId + (isBznmTotNoDisabled ? '0000' : data.bznmTotNo),
dmdAplyPswd: data.dmdAplyPswd,
}
actionLogin(param as unknown as DemandCompType);
};
const formFields: Array<{
name: keyof SdmLoginForm;
label: string;
component: FieldComponent | undefined;
}> = [
{
name: "dmdInfoId",
label: "사업자등록번호",
component: (field) => (
<Input
placeholder="사업자등록번호를 입력하세요"
type="text"
className="!mt-5 !mb-5 !text-xl text-gray-600"
{...field}
value={formatBusinessNumber(field.value as string)}
onChange={(e) => {
const dmdId = e.target.value.replace(/\D/g, "").slice(0, 10);
field.onChange(dmdId);
}}
/>
)
},
{
name: "bznmTotNo",
label: BZNM_TOT_NO || "종사업자등록번호",
component: (field) => (
<FormUi
label="종사업자등록번호"
onCheckboxChange={handleCheckboxChange}
isDisabled={isBznmTotNoDisabled}
>
<Input
placeholder="여러사업장이 있을 경우 입력해주세요"
type="text"
className="!mt-5 !text-xl text-gray-600"
disabled={isBznmTotNoDisabled}
{...field}
/>
</FormUi>
)
},
{
name: "password",
label: "비밀번호",
component: (field) => (
<Input
placeholder="비밀번호를 입력하세요"
type="password"
className="!mt-5 !mb-5 !text-xl text-gray-600"
{...field}
/>
)
}
];
return (
<>
<div>
<FormProvider {...form}>
{formFields.map(({ name, label, component }) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
label === '종사업자등록번호' ?
<>
{component && component(field)}
</>
: <FormUi label={label}>
{component && component(field)}
</FormUi>
)}
/>
))}
</FormProvider>
<div className="flex justify-center pt-4">
<div className="flex flex-col items-center">
<Button
onClick={handleLogin}
className="w-32 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md transition-colors duration-200"
>
로그인
</Button>
<div className="text-xl mt-2 text-gray-400 cursor-pointer" onClick={initPassword}>비밀번호 초기화 요청</div>
</div>
</div>
</div>
</>
);
}