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_CLAIM_NAME = Authorization
|
||||||
NEXT_PUBLIC_AUTH_USER_ID = token-id
|
NEXT_PUBLIC_AUTH_USER_ID = token-id
|
||||||
NEXT_PUBLIC_ACCESS_TOKEN = access-token
|
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 "./globals.css";
|
||||||
import { setCookie } from "../src/shared/lib";
|
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({
|
export default function RootLayout({
|
||||||
children,
|
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 === "/") {
|
if (pathname === "/") {
|
||||||
console.log(" maim 화면이동 ");
|
console.log(" maim 화면이동 ");
|
||||||
return NextResponse.redirect(new URL("/reuse", req.url));
|
return NextResponse.redirect(new URL("/main", req.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/attach-service")) {
|
if (pathname.startsWith("/attach-service")) {
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ const nextConfig: NextConfig = {
|
|||||||
proxyTimeout: 600000,
|
proxyTimeout: 600000,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 🔥 반드시 필요
|
// // 🔥 반드시 필요
|
||||||
webpack(config) {
|
// webpack(config) {
|
||||||
config.resolve.alias = {
|
// config.resolve.alias = {
|
||||||
...(config.resolve.alias || {}),
|
// ...(config.resolve.alias || {}),
|
||||||
// monorepo 구조 기준
|
// // monorepo 구조 기준
|
||||||
"@common": path.resolve(__dirname, "../../packages/common"),
|
// "@common": path.resolve(__dirname, "../../packages/common"),
|
||||||
};
|
// };
|
||||||
return config;
|
// return config;
|
||||||
},
|
// },
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Binary file not shown.
@@ -2,7 +2,7 @@ export { getCookie, setCookie, removeCookie } from "./cookie";
|
|||||||
// export { useMutationWrapper } from "./use-mutation-wrapper";
|
// export { useMutationWrapper } from "./use-mutation-wrapper";
|
||||||
// export { useAlertStore } from "./use-alert-store";
|
// export { useAlertStore } from "./use-alert-store";
|
||||||
// export { useDataTable } from "./use-data-table";
|
// 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 { useUserStore } from "./use-user-store";
|
||||||
// export { usePrompt } from "./use-prompt";
|
// export { usePrompt } from "./use-prompt";
|
||||||
// export { useSelectPrompt } from "./use-select-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
|
specifier: ^5
|
||||||
version: 5.9.3
|
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:
|
apps/fo:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@common/assets':
|
'@common/assets':
|
||||||
|
|||||||
Reference in New Issue
Block a user