dm_new menu
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
NEXT_PUBLIC_SITE_ID = DM
|
||||
NEXT_PUBLIC_SITE_ID = DN
|
||||
NEXT_PUBLIC_CLAIM_NAME = Authorization
|
||||
NEXT_PUBLIC_AUTH_USER_ID = token-id
|
||||
NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
||||
|
||||
@@ -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,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" stroke-linejoin="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,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,171 @@
|
||||
// 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/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" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<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,171 @@
|
||||
// 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" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 사업명 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// app/(with-layout)/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 (
|
||||
<>
|
||||
<TopNav />
|
||||
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import "./globals.css";
|
||||
import { setCookie } from "../src/shared/lib";
|
||||
|
||||
setCookie("site-id-key", process.env.NEXT_PUBLIC_SITE_ID || "DM");
|
||||
setCookie("site-id-key", process.env.NEXT_PUBLIC_SITE_ID || "DN");
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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="#"
|
||||
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 @@
|
||||
// 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="text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 소개</h2>
|
||||
<div className="h-2 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="text-[40px] font-extrabold tracking-tight text-slate-900 leading-none">물 재이용 사례</h2>
|
||||
<div className="h-2 bg-gradient-to-r from-sky-300 to-green-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭메뉴 영역 */}
|
||||
<CaseTabsCarousel />
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// app/main/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 (
|
||||
<>
|
||||
<TopNav />
|
||||
<div className="pt-[83px]">{children}</div> {/* TopNav 높이만큼 */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,495 @@
|
||||
/**====================================
|
||||
* 보편적으로 쓰는 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 || "DN";
|
||||
|
||||
export async function fetchAnonymousMenu(siteId = REUSE_SITE_ID): Promise<MenuItem[]> {
|
||||
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[];
|
||||
if (!Array.isArray(data)) return [];
|
||||
return normalizeMenus(data);
|
||||
}
|
||||
|
||||
function isPathMatched(pathname: string, urlPath: string) {
|
||||
if (!urlPath) return false;
|
||||
return pathname === urlPath || pathname.startsWith(`${urlPath}/`);
|
||||
}
|
||||
|
||||
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) => isPathMatched(pathname, menu.urlPath))
|
||||
.sort((a, b) => b.urlPath.length - a.urlPath.length);
|
||||
|
||||
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) => {
|
||||
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[];
|
||||
if (!Array.isArray(data)) return [];
|
||||
return normalizeMenus(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
export type CommonStore = CommonType & CommonAction;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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; // 재이용시설 코드
|
||||
}
|
||||
@@ -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)
|
||||
})}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
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("수요처 로그인에 성공하였습니다.");
|
||||
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;
|
||||
@@ -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", "DM");
|
||||
tokenManager.setToken("site-theme-key", "green");
|
||||
}
|
||||
|
||||
return {
|
||||
...initState,
|
||||
...CommonReducer(set),
|
||||
...SdmReducer(set),
|
||||
...DmMapReducer(set),
|
||||
...DmStatusReducer(set),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
<span className="text-gray-500">종사업자번호 : </span> {item.dmdInfoId.substring(10,14)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-500">사업자번호 : </span> {item.dmdInfoId.substring(0,10)}
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function middleware(req: NextRequest) {
|
||||
|
||||
if (pathname === "/") {
|
||||
console.log(" maim 화면이동 ");
|
||||
return NextResponse.redirect(new URL("/reuse", req.url));
|
||||
return NextResponse.redirect(new URL("/main", req.url));
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/attach-service")) {
|
||||
|
||||
@@ -10,15 +10,15 @@ const nextConfig: NextConfig = {
|
||||
proxyTimeout: 600000,
|
||||
},
|
||||
|
||||
// 🔥 반드시 필요
|
||||
webpack(config) {
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias || {}),
|
||||
// monorepo 구조 기준
|
||||
"@common": path.resolve(__dirname, "../../packages/common"),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
// // 🔥 반드시 필요
|
||||
// webpack(config) {
|
||||
// config.resolve.alias = {
|
||||
// ...(config.resolve.alias || {}),
|
||||
// // monorepo 구조 기준
|
||||
// "@common": path.resolve(__dirname, "../../packages/common"),
|
||||
// };
|
||||
// return config;
|
||||
// },
|
||||
|
||||
async rewrites() {
|
||||
return {
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@ export { getCookie, setCookie, removeCookie } from "./cookie";
|
||||
// export { useMutationWrapper } from "./use-mutation-wrapper";
|
||||
// export { useAlertStore } from "./use-alert-store";
|
||||
// export { useDataTable } from "./use-data-table";
|
||||
// export { useMenuStore } from "./use-menu-store";
|
||||
export { useMenuStore } from "./use-menu-store";
|
||||
// export { useUserStore } from "./use-user-store";
|
||||
// export { usePrompt } from "./use-prompt";
|
||||
// export { useSelectPrompt } from "./use-select-prompt";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { MenuStore } from "../store/menu-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useMenuStore = <T>(selector: (store: MenuStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useMenuStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.menuStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StoreProvider } from "./store-provider";
|
||||
export { PromptProvider } from "./prompt-provider";
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createContext, useState } from "react";
|
||||
|
||||
type SelectData = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface PromptContext {
|
||||
message?: string;
|
||||
selectData?: SelectData[];
|
||||
selectionGuide?: string;
|
||||
resolve?: ({
|
||||
resolved,
|
||||
value,
|
||||
}: {
|
||||
resolved: boolean;
|
||||
value?: string;
|
||||
}) => void;
|
||||
setMessage: (message: string) => void;
|
||||
setSelectData: (selectData: SelectData[]) => void;
|
||||
setSelectionGuide: (message: string) => void;
|
||||
setResolve: (resolve: () => void) => void;
|
||||
}
|
||||
|
||||
export const PromptContext = createContext<PromptContext>({
|
||||
message: "",
|
||||
selectData: [],
|
||||
selectionGuide: "",
|
||||
resolve() {},
|
||||
setMessage() {},
|
||||
setSelectData() {},
|
||||
setSelectionGuide() {},
|
||||
setResolve() {},
|
||||
});
|
||||
|
||||
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [message, setMessage] = useState<string>();
|
||||
const [selectData, setSelectData] = useState<SelectData[]>([]);
|
||||
const [selectionGuide, setSelectionGuide] = useState<string>();
|
||||
const [resolve, setResolve] =
|
||||
useState<
|
||||
({ resolved, value }: { resolved: boolean; value?: string }) => void
|
||||
>();
|
||||
|
||||
return (
|
||||
<PromptContext.Provider
|
||||
value={{
|
||||
message,
|
||||
selectData,
|
||||
selectionGuide,
|
||||
resolve,
|
||||
setMessage,
|
||||
setSelectData,
|
||||
setSelectionGuide,
|
||||
setResolve,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PromptContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useRef } from "react";
|
||||
import {
|
||||
createAlertStore,
|
||||
createAttachmentStore,
|
||||
createMenuStore,
|
||||
createUserStore,
|
||||
} from "../store";
|
||||
|
||||
type StoreContextType = {
|
||||
alertStore: ReturnType<typeof createAlertStore>;
|
||||
attachmentStore: ReturnType<typeof createAttachmentStore>;
|
||||
menuStore: ReturnType<typeof createMenuStore>;
|
||||
userStore: ReturnType<typeof createUserStore>;
|
||||
};
|
||||
|
||||
export const StoreContext = createContext<StoreContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const storeRef = useRef<StoreContextType | null>(null);
|
||||
if (storeRef.current === null) {
|
||||
storeRef.current = {
|
||||
alertStore: createAlertStore(),
|
||||
attachmentStore: createAttachmentStore(),
|
||||
menuStore: createMenuStore(),
|
||||
userStore: createUserStore(),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export type AlertState = {
|
||||
message: string;
|
||||
callback?: () => void;
|
||||
};
|
||||
|
||||
export type AlertActions = {
|
||||
setMessage: (message: string, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
export type AlertStore = AlertState & AlertActions;
|
||||
|
||||
export const defaultInitState: AlertState = {
|
||||
message: "",
|
||||
callback: undefined,
|
||||
};
|
||||
|
||||
export const createAlertStore = (initState: AlertState = defaultInitState) => {
|
||||
return createStore<AlertStore>()((set) => ({
|
||||
...initState,
|
||||
setMessage: (message: string, callback?: () => void) =>
|
||||
set(() => ({ message, callback })),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export type AttachmentState = {
|
||||
attachmentFileCode: string;
|
||||
};
|
||||
|
||||
export type AttachmentActions = {
|
||||
setAttachmentFileCode: (attachmentFileCode: string) => void;
|
||||
};
|
||||
|
||||
export type AttachmentStore = AttachmentState & AttachmentActions;
|
||||
|
||||
export const defaultInitState: AttachmentState = {
|
||||
attachmentFileCode: "",
|
||||
};
|
||||
|
||||
export const createAttachmentStore = (
|
||||
initState: AttachmentState = defaultInitState,
|
||||
) => {
|
||||
return createStore<AttachmentStore>()((set) => ({
|
||||
...initState,
|
||||
setAttachmentFileCode: (attachmentFileCode: string) =>
|
||||
set({ attachmentFileCode }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { createAlertStore } from "./alert-store";
|
||||
export { createAttachmentStore } from "./attachment-store";
|
||||
export { createMenuStore, type BbsType } from "./menu-store";
|
||||
export { createUserStore, type IUserInfo } from "./user-store";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export const BBS_TYPE = {
|
||||
BBSNOTICE: "BBSNOTICE",
|
||||
BBSNORMAL: "BBSNORMAL",
|
||||
BBSFAQ: "BBSFAQ",
|
||||
BBSIMAGE: "BBSIMAGE",
|
||||
BBSMOVIE: "BBSMOVIE",
|
||||
} as const;
|
||||
|
||||
export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE];
|
||||
|
||||
export interface IBbs {
|
||||
bbsId: number;
|
||||
bbsTypeCd: BbsType;
|
||||
bbsTypeNm: string;
|
||||
bbsNm: string;
|
||||
bbsExpln: string;
|
||||
rfrncUseYn: string;
|
||||
atflUseYn: string;
|
||||
atflMaxCnt: number;
|
||||
atflMaxFileSz: number;
|
||||
ansUseYn: string;
|
||||
ansUseAuthrt: string;
|
||||
cmntUseYn: string;
|
||||
dgstfnEvlUseYn: string;
|
||||
smsUseYn: string;
|
||||
upndFixUseYn: string;
|
||||
pstgPrdUseYn: string;
|
||||
popupUseYn: string;
|
||||
delYn: string;
|
||||
useYn: string;
|
||||
mdfcnDt: string;
|
||||
mdfrNm: string;
|
||||
}
|
||||
|
||||
interface IMenu {
|
||||
menuCd: number;
|
||||
menuKornNm: string;
|
||||
menuEngNm: string;
|
||||
parentId: number | null;
|
||||
sortSeq: number;
|
||||
iconNm?: string;
|
||||
children: IMenu[] | null;
|
||||
levelNo: number;
|
||||
urlPath: string;
|
||||
menuLnkgTypeCd: string;
|
||||
menuLnkgSn: number;
|
||||
menuPath: string;
|
||||
qrcd: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
requiredUrlPath: boolean;
|
||||
}
|
||||
|
||||
interface IAuthority {
|
||||
mngAuthrtYn: string;
|
||||
inqAuthrtYn: string;
|
||||
regAuthrtYn: string;
|
||||
mdfcnAuthrtYn: string;
|
||||
delAuthrtYn: string;
|
||||
}
|
||||
|
||||
export type MenuState = {
|
||||
bbsInfo: IBbs | null;
|
||||
currentMenuInfo: IMenu | null;
|
||||
pageMenuAuthority: IAuthority | null;
|
||||
};
|
||||
|
||||
export type MenuActions = {
|
||||
setBbsInfo: (bbs: IBbs) => void;
|
||||
setCurrentMenuInfo: (menu: IMenu) => void;
|
||||
setPageMenuAuthority: (authority: IAuthority) => void;
|
||||
};
|
||||
|
||||
export type MenuStore = MenuState & MenuActions;
|
||||
|
||||
export const defaultInitState: MenuState = {
|
||||
currentMenuInfo: null,
|
||||
bbsInfo: null,
|
||||
pageMenuAuthority: null,
|
||||
};
|
||||
|
||||
export const createMenuStore = (initState: MenuState = defaultInitState) => {
|
||||
return createStore<MenuStore>()((set) => ({
|
||||
...initState,
|
||||
setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }),
|
||||
setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }),
|
||||
setPageMenuAuthority: (authority: IAuthority) =>
|
||||
set({ pageMenuAuthority: authority }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export interface IUserInfo {
|
||||
regDt: string;
|
||||
mdfcnDt: string;
|
||||
rgtr: string;
|
||||
mdfr: string;
|
||||
adminRoleYn: string;
|
||||
userUnqId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
encryptedPassword: string;
|
||||
refreshToken: string;
|
||||
userStateCode: string;
|
||||
lastLoginDate: string;
|
||||
loginFailCount: number;
|
||||
pswdHnt: string;
|
||||
pswdAns: string;
|
||||
mphn: string;
|
||||
telno: string;
|
||||
zip: string;
|
||||
addr: string;
|
||||
daddr: string;
|
||||
laeSeCd: string;
|
||||
ogdpSeCd: string;
|
||||
aprvYn: string;
|
||||
autzrId: string;
|
||||
expryYmd: string;
|
||||
lastPswdChgDt: string;
|
||||
googleId: string;
|
||||
kakaoId: string;
|
||||
naverId: string;
|
||||
isSocialUser: boolean;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export type UserState = {
|
||||
userInfo: IUserInfo | null;
|
||||
};
|
||||
|
||||
export type UserActions = {
|
||||
setUserInfo: (userInfo: IUserInfo | null) => void;
|
||||
};
|
||||
|
||||
export type UserStore = UserState & UserActions;
|
||||
|
||||
export const defaultInitState: UserState = {
|
||||
userInfo: null,
|
||||
};
|
||||
|
||||
export const createUserStore = (initState: UserState = defaultInitState) => {
|
||||
return createStore<UserStore>()((set) => ({
|
||||
...initState,
|
||||
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
|
||||
}));
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
Generated
+70
@@ -656,6 +656,76 @@ importers:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
|
||||
apps/dm_new:
|
||||
dependencies:
|
||||
'@common/assets':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/common/assets
|
||||
'@common/business_components':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/common/business_components
|
||||
'@common/components':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/common/components
|
||||
'@hookform/resolvers':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(react-hook-form@7.68.0(react@19.2.3))
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
js-sha512:
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
lucide-react:
|
||||
specifier: ^0.474.0
|
||||
version: 0.474.0(react@19.2.3)
|
||||
next:
|
||||
specifier: 15.3.8
|
||||
version: 15.3.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react-hook-form:
|
||||
specifier: ^7.54.2
|
||||
version: 7.68.0(react@19.2.3)
|
||||
react-hot-toast:
|
||||
specifier: ^2.5.2
|
||||
version: 2.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
tailwind-merge:
|
||||
specifier: ^3.0.1
|
||||
version: 3.4.0
|
||||
tailwindcss-animate:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(tailwindcss@3.4.19)
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3
|
||||
version: 3.3.3
|
||||
'@types/webpack':
|
||||
specifier: ^5.28.5
|
||||
version: 5.28.5
|
||||
eslint:
|
||||
specifier: ^9
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
eslint-config-next:
|
||||
specifier: 15.1.7
|
||||
version: 15.1.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
postcss:
|
||||
specifier: ^8
|
||||
version: 8.5.6
|
||||
tailwindcss:
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.19
|
||||
typescript:
|
||||
specifier: ^5
|
||||
version: 5.9.3
|
||||
|
||||
apps/fo:
|
||||
dependencies:
|
||||
'@common/assets':
|
||||
|
||||
Reference in New Issue
Block a user