501 lines
18 KiB
TypeScript
501 lines
18 KiB
TypeScript
// app/(with-layout)/apply/register/page.tsx
|
|
/**
|
|
* 수요정보등록(수정) 화면
|
|
*
|
|
* 🔗 사용 API
|
|
* - 수요정보등록: POST /reuseFc/request/demandInfo
|
|
* - 수요정보조회: GET /reuseFc/request/' + dmdInfoId - getSdmInfoStatusList
|
|
*/
|
|
"use client";
|
|
|
|
import { useMemo, useState, useRef } from "react";
|
|
import SubPageHeader from "@/components/SubPageHeader";
|
|
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
|
|
import CandidateSiteModal, { type CandidateSite } from "@/components/CandidateSiteModal";
|
|
|
|
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 [openCand, setOpenCand] = useState(false);
|
|
const [selectedCands, setSelectedCands] = useState<CandidateSite[]>([]);
|
|
|
|
// 기본정보
|
|
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={() => setOpenCand(true)}
|
|
>
|
|
후보지 조회
|
|
</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>
|
|
|
|
<CandidateSiteModal
|
|
open={openCand}
|
|
onClose={() => setOpenCand(false)}
|
|
initialSelected={selectedCands}
|
|
onApply={(list) => {
|
|
setSelectedCands(list);
|
|
|
|
// ✅ cand1~3 입력칸에 반영(시설명만 넣는 버전)
|
|
setCand1(list[0]?.name ?? "");
|
|
setCand2(list[1]?.name ?? "");
|
|
setCand3(list[2]?.name ?? "");
|
|
|
|
setOpenCand(false);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ----------------------------- UI helpers ----------------------------- */
|
|
|
|
function Divider() {
|
|
return <div className="h-px w-full bg-neutral-200" />;
|
|
}
|
|
|
|
function LabelCell({
|
|
children,
|
|
required,
|
|
}: {
|
|
children: React.ReactNode;
|
|
required?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="col-span-12 md:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
|
|
<span className="inline-flex items-center gap-1">
|
|
{children}
|
|
{required ? <span className="text-red-500">*</span> : null}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FormRow({
|
|
label,
|
|
required,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
required?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-12">
|
|
<LabelCell required={required}>{label}</LabelCell>
|
|
<div className="col-span-12 md:col-span-10 p-6">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QualityRow({
|
|
leftLabel,
|
|
leftRequired,
|
|
leftControl,
|
|
rightLabel,
|
|
rightControl,
|
|
}: {
|
|
leftLabel: React.ReactNode;
|
|
leftRequired?: boolean;
|
|
leftControl: React.ReactNode;
|
|
rightLabel: React.ReactNode;
|
|
rightControl: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-12">
|
|
{/* 좌측 라벨 */}
|
|
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="leading-6">{leftLabel}</span>
|
|
{leftRequired ? <span className="text-red-500">*</span> : null}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 좌측 입력 */}
|
|
<div className="col-span-12 lg:col-span-4 p-6">{leftControl}</div>
|
|
|
|
{/* 우측 라벨 */}
|
|
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
|
|
<span className="leading-6">{rightLabel}</span>
|
|
</div>
|
|
|
|
{/* 우측 입력 */}
|
|
<div className="col-span-12 lg:col-span-4 p-6">{rightControl}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextInput({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
disabled,
|
|
className,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className={[
|
|
"h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none",
|
|
"focus:border-neutral-500",
|
|
disabled ? "cursor-not-allowed" : "",
|
|
className ?? "",
|
|
].join(" ")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function InlineSuffixInput({
|
|
value,
|
|
onChange,
|
|
suffix,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
suffix: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<TextInput value={value} onChange={onChange} className="max-w-[200px]" />
|
|
<span className="text-sm text-neutral-600">{suffix}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DateInput({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
|
|
const ref = useRef<HTMLInputElement | null>(null);
|
|
|
|
const openPicker = () => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
|
|
// Chrome/Edge 등 지원
|
|
if (typeof (el as any).showPicker === "function") {
|
|
(el as any).showPicker();
|
|
return;
|
|
}
|
|
|
|
// fallback (일부 브라우저)
|
|
el.focus();
|
|
el.click();
|
|
};
|
|
return (
|
|
<div className="relative w-full max-w-[200px]">
|
|
<input
|
|
ref={ref}
|
|
type="date"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className={[
|
|
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
|
|
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
|
|
"[&::-webkit-calendar-picker-indicator]:opacity-0",
|
|
"[&::-webkit-calendar-picker-indicator]:absolute",
|
|
"[&::-webkit-calendar-picker-indicator]:right-0",
|
|
"[&::-webkit-calendar-picker-indicator]:w-10",
|
|
"[&::-webkit-calendar-picker-indicator]:h-full",
|
|
"[&::-webkit-calendar-picker-indicator]:cursor-pointer",
|
|
].join(" ")}
|
|
/>
|
|
|
|
{/* ✅ 클릭 가능한 아이콘 버튼 */}
|
|
<button
|
|
type="button"
|
|
onClick={openPicker}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-500 hover:text-neutral-700"
|
|
aria-label="달력 열기"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|